From 4d4940ce55b9815d44dc0fd7924f037b6f450c05 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 26 May 2026 12:55:33 +1000 Subject: [PATCH 01/27] Mark SeqCli.EndToEnd as a non-test project so that it's not picked up automatically by dotnet test --- test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj index 00eec61a..33dd29e3 100644 --- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj +++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj @@ -3,6 +3,8 @@ Exe net10.0 + false + false From 184952b26f32e3e55d34d9069e6e8de89d296914 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 26 May 2026 13:59:18 +1000 Subject: [PATCH 02/27] seqcli skills install command --- .../Cli/Commands/Skills/InstallCommand.cs | 56 +++++++++++++++++++ src/SeqCli/SeqCli.csproj | 3 + .../Resources/seq-query-grammar/SKILL.md | 0 src/SeqCli/Skills/SkillInstaller.cs | 52 +++++++++++++++++ .../Skills/SkillsInstallTestCase.cs | 31 ++++++++++ 5 files changed, 142 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Skills/InstallCommand.cs create mode 100644 src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md create mode 100644 src/SeqCli/Skills/SkillInstaller.cs create mode 100644 test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs new file mode 100644 index 00000000..9be88fc1 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -0,0 +1,56 @@ +// 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.IO; +using System.Threading.Tasks; +using SeqCli.Skills; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.Skills; + +[Command("skills", "install", "Install or update Seq agent skills", + Example = "seqcli skills install --global --agent claude")] +class InstallCommand : Command +{ + bool _global; + string? _agent; + + public InstallCommand() + { + Options.Add( + "g|global", + "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills.`", + _ => _global = true); + + Options.Add( + "a=|agent=", + "The agent name to install skills for; the default is the generic name `agents`.", + t => _agent = ArgumentString.Normalize(t)); + } + + protected override Task Run() + { + var skillsPath = Path.Combine( + _global ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Environment.CurrentDirectory, + $".{_agent?.ToLowerInvariant() ?? "agents"}", + "skills"); + + Log.Information("Installing skills to {SkillsPath}", skillsPath); + SkillInstaller.Install(skillsPath); + + return Task.FromResult(0); + } +} \ No newline at end of file diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index ce5d3ea0..df304e8c 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -29,6 +29,9 @@ PreserveNewest + + PreserveNewest + diff --git a/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md b/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md new file mode 100644 index 00000000..e69de29b diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs new file mode 100644 index 00000000..90736448 --- /dev/null +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -0,0 +1,52 @@ +// 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.IO; +using Serilog; + +namespace SeqCli.Skills; + +static class SkillInstaller +{ + public static void Install(string destinationPath) + { + var sourcePath = Path.Combine(AppContext.BaseDirectory, "Skills"); + + foreach (var skillSourceDirectory in Directory.EnumerateDirectories(sourcePath)) + { + var skillName = Path.GetFileName(skillSourceDirectory); + var destination = Path.Combine(destinationPath, skillName); + + Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath); + + CopyFilesRecursive(skillSourceDirectory, destination); + } + } + + static void CopyFilesRecursive(string source, string destination) + { + Directory.CreateDirectory(destination); + + foreach (var file in Directory.EnumerateFiles(source)) + { + File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + } + + foreach (var directory in Directory.EnumerateDirectories(source)) + { + CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory))); + } + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs new file mode 100644 index 00000000..7f3a9623 --- /dev/null +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Skills; + +public class SkillsInstallTestCase : ICliTestCase +{ + public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + using var tmp = new TestDataFolder(); + var previous = Environment.CurrentDirectory; + Environment.CurrentDirectory = tmp.Path; + try + { + var exit = runner.Exec("skills install -a test-agent"); + Assert.Equal(0, exit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-query-grammar/SKILL.md"))); + } + finally + { + Environment.CurrentDirectory = previous; + } + + return Task.CompletedTask; + } +} \ No newline at end of file From b3a406225d0324f9ab70ef5f5348446e0749ffb0 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 11:08:52 +1000 Subject: [PATCH 03/27] More skill WIP --- .claude/skills/seq-search-and-query/SKILL.md | 8 + src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 59 +++ src/SeqCli/Cli/Commands/SearchCommand.cs | 6 +- src/SeqCli/Ingestion/LogShipper.cs | 2 +- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 191 ++++++++ src/SeqCli/Mcp/McpSession.cs | 76 ++++ src/SeqCli/Mcp/Tools/Search/SearchTool.cs | 258 +++++++++++ .../PlainText/LogEvents/LogEventBuilder.cs | 2 +- src/SeqCli/SeqCli.csproj | 4 + .../Resources/seq-query-grammar/SKILL.md | 0 .../Resources/seq-search-and-query/SKILL.md | 416 ++++++++++++++++++ .../Skills/SkillsInstallTestCase.cs | 2 +- 12 files changed, 1018 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/seq-search-and-query/SKILL.md create mode 100644 src/SeqCli/Cli/Commands/Mcp/RunCommand.cs create mode 100644 src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs create mode 100644 src/SeqCli/Mcp/McpSession.cs create mode 100644 src/SeqCli/Mcp/Tools/Search/SearchTool.cs delete mode 100644 src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md create mode 100644 src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md new file mode 100644 index 00000000..f6467791 --- /dev/null +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -0,0 +1,8 @@ +--- +name: seq-search-and-query +description: Search and query logs and spans in Seq. Use when interacting with Seq. +license: Apache-2.0 +metadata: + author: Datalust and Contributors +--- + diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs new file mode 100644 index 00000000..5bcb900b --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -0,0 +1,59 @@ +// 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.Threading.Tasks; +using Autofac.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Mcp; +using SeqCli.Mcp.Tools.Search; +using Serilog; + +namespace SeqCli.Cli.Commands.Mcp; + +[Command("mcp", "run", "Run an MCP (Model Context Protocol) server on STDIO")] +class RunCommand: Command +{ + readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; + + public RunCommand() + { + _connection = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + + var builder = Host.CreateApplicationBuilder(); + builder.ConfigureContainer(new AutofacServiceProviderFactory()); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); + builder.Services.AddSingleton(); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools([ + typeof(SearchTool) + ]); + + await builder.Build().RunAsync(); + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 2d096195..cb8166b8 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -128,7 +128,7 @@ protected override async Task Run() } } - LogEvent ToSerilogEvent(EventEntity evt) + internal static LogEvent ToSerilogEvent(EventEntity evt) { return new LogEvent( DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(), @@ -149,12 +149,12 @@ static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart toke return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}"); } - LogEventProperty CreateProperty(string name, object value) + static LogEventProperty CreateProperty(string name, object value) { return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); } - LogEventPropertyValue CreatePropertyValue(object value) + static LogEventPropertyValue CreatePropertyValue(object value) { switch (value) { diff --git a/src/SeqCli/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index 97060913..c5738604 100644 --- a/src/SeqCli/Ingestion/LogShipper.cs +++ b/src/SeqCli/Ingestion/LogShipper.cs @@ -181,7 +181,7 @@ static async Task ReadBatchAsync( if (isLast || batch.Count != 0 || totalWaitMS > maxWaitMS) break; - // Nothing to to ship; wait to try to fill a batch. + // Nothing to ship; wait to try to fill a batch. await Task.Delay(idleWaitMS); totalWaitMS += idleWaitMS; continue; diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs new file mode 100644 index 00000000..a1ee6147 --- /dev/null +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; +using SeqCli.Syntax; + +namespace SeqCli.Mcp.Formatting; + +// 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 readonly object UndefinedValue = new(); + + [GeneratedRegex("[_a-zA-Z][_a-zA-Z0-9]*")] + private static partial Regex IdentifierRegex(); + + public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) + { + WriteObject( + output, + true, + ("@Id", evt.Id), + ("@Timestamp", DateTimeOffset.Parse(evt.Timestamp).UtcDateTime), + ("@Level", evt.Level ?? "Information"), + ("@Message", evt.RenderedMessage), + ("@MessageTemplate", ReconstructTemplate(evt.MessageTemplateTokens)), + ("@EventType", ParseEventType(evt.EventType)), + ("@Exception", evt.Exception ?? UndefinedValue), + ("@Elapsed", evt.Elapsed ?? UndefinedValue), + ("@TraceId", evt.TraceId ?? UndefinedValue), + ("@SpanId", evt.SpanId ?? UndefinedValue), + ("@SpanKind", evt.SpanKind ?? UndefinedValue), + ("@Start", evt.Start != null ? DateTimeOffset.Parse(evt.Start).UtcDateTime : UndefinedValue), + ("@ParentId", evt.ParentId ?? UndefinedValue), + ("@Properties", evt.Properties?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Properties)) : UndefinedValue), + ("@Scope", evt.Scope?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Scope)) : UndefinedValue), + ("@Resource", evt.Resource?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Resource)) : UndefinedValue), + ("@Definitions", evt.Definitions?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Definitions)) : UndefinedValue) + ); + } + + static uint ParseEventType(string dollarPrefixedHex) + { + return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); + } + + static string ReconstructTemplate(IEnumerable tokens) + { + return string.Concat(tokens.Select(t => t.RawText)); + } + + static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) + { + output.Write('{'); + var first = true; + foreach (var (name, value) in members) + { + if (value == UndefinedValue) + continue; + + if (first) + first = false; + else + output.Write(", "); + + if (topLevel) + { + output.Write(name); + } + else + { + WriteMemberName(output, name); + } + + output.Write(": "); + + if (value is Action valueWriter) + { + valueWriter(output); + } + else + { + WriteValue(output, value); + } + } + output.Write('}'); + } + + static void WriteValue(TextWriter output, object? value) + { + if (value == UndefinedValue) + { + // This should never occur, but works in case it becomes necessary. + output.Write("@Undefined"); + return; + } + + switch (value) + { + case null: + output.Write("null"); + return; + case true: + output.Write("true"); + return; + case false: + output.Write("false"); + return; + } + + if (value is string s) + { + output.Write('\''); + output.Write(s.Replace("'", "''")); + output.Write('\''); + return; + } + + if (value is decimal + or double or float or Half + or byte or ushort or uint or ulong or UInt128 or + sbyte or short or int or long or Int128) + { + output.Write(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + return; + } + + if (value is TimeSpan ts) + { + output.Write(DurationMoniker.FromTimeSpan(ts)); + return; + } + + if (value is DateTime dt) + { + output.Write($"DateTime('{dt:O}')"); + } + + if (value is JArray ja) + { + var first = false; + output.Write('['); + foreach (var element in ja) + { + if (first) + first = false; + else + output.Write(", "); + WriteValue(output, element); + } + output.Write(']'); + return; + } + + if (value is JObject jo) + { + WriteObject(output, false, jo.Properties().Select(p => (p.Name, (object?)p.Value))); + } + + if (value is JValue jt) + { + WriteValue(output, jt.Value); + return; + } + + WriteValue(output, value.ToString()); + } + + static void WriteMemberName(TextWriter output, string name) + { + if (IdentifierRegex().IsMatch(name)) + { + output.Write(name); + } + else + { + WriteValue(output, name); + } + } + + static void WritePropertiesObject(TextWriter output, List members) + { + WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); + } +} diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs new file mode 100644 index 00000000..61df7c13 --- /dev/null +++ b/src/SeqCli/Mcp/McpSession.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; +using Seq.Api.Model.Events; +using System; + +namespace SeqCli.Mcp; + +class McpSession +{ + readonly Lock _sync = new(); + int _nextId = 1; + readonly Dictionary _resultIdToEventId = new(); + readonly Dictionary _eventIdToResult = new(); + + public string ImportSearchResult(EventEntity evt) + { + lock (_sync) + { + if (_eventIdToResult.TryGetValue(evt.Id, out var existing)) + return FormatResultId(existing.Item1); + var resultId = _nextId; + _nextId += 1; + _resultIdToEventId.Add(resultId, evt.Id); + _eventIdToResult.Add(evt.Id, (resultId, evt)); + return FormatResultId(resultId); + } + } + + static string FormatResultId(int resultId) + { + return "E" + resultId.ToString("X5"); + } + + static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) + { + if (!formatted.StartsWith('E') || !int.TryParse(formatted.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + { + resultId = null; + return false; + } + + resultId = parsed; + return true; + } + + public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, [NotNullWhen(false)] out string? error) + { + if (!TryParseResultId(resultId, out var parsed)) + { + result = null; + error = + "The result id is not correctly formatted; result ids are strings beginning with `E`, followed by a short character string."; + return false; + } + + lock (_sync) + { + if (!_resultIdToEventId.TryGetValue(parsed.Value, out var eventId)) + { + result = null; + error = + "A matching result wasn't found among recent searches. Try retrieving a fresh result id by searching again (using a very narrow time range if possible)."; + return false; + } + + if (!_eventIdToResult.TryGetValue(eventId, out var pair)) + throw new InvalidOperationException("Missing result mapping."); + + result = pair.Item2; + error = null; + return true; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs new file mode 100644 index 00000000..0080f6db --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Seq.Api; +using Seq.Api.Model.Events; +using Seq.Api.Model.Expressions; +using Seq.Syntax.Templates; +using SeqCli.Cli.Commands; +using SeqCli.Mapping; +using SeqCli.Mcp.Formatting; +using Serilog; +using Serilog.Events; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Search; + +[McpServerToolType] +class SearchTool(McpSession session, SeqConnection connection) +{ + const string ResultIdPropertyName = "__seqcli_ResultId"; + static readonly ExpressionTemplate SearchResultFormatter = new ( + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{Substring(ToString(@x), 0, 140)}}" + ); + + [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] + [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + + "a `result_id` of the form `E..` which is valid in the current MCP session. Individual events can be " + + "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + + "skill when calling this tool.")] + [return: Description("Search results and status information.")] + public async Task SearchEvents( + [Description("The maximum number of events to return.")] + [Range(1, 1000)] + int limit, + [Description("A Seq search expression evaluated over event properties.")] + string? predicate = null, + [Description("The search timeout, in seconds; the default is 45.")] + [Range(5, 180)] + int timeoutSeconds = 45, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(predicate)) + { + if (!predicate.Contains("@Timestamp") || predicate.Contains("@Id")) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = "The predicate doesn't adequately constrain the search range (by `@Timestamp` or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", + } + ] + }; + } + + ExpressionPart strict; + try + { + strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); + } + catch (Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = "The Seq API client failed while attempting to validate the search expression." + }, + new TextContentBlock + { + Text = ex.ToString() + } + ], + }; + } + if (strict.MatchedAsText) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = $"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}" + } + ], + }; + } + } + + var resultsLock = new Lock(); + Exception? error = null; + var results = new List(); + var timeout = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), cancellationToken); + using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var cancelEnumerateToken = cancelEnumerate.Token; + var enumerate = Task.Run(async () => + { + try + { + await foreach (var evt in connection.Events.EnumerateAsync( + filter: predicate, + count: limit, + cancellationToken: cancelEnumerateToken)) + { + lock (resultsLock) + { + results.Add(evt); + } + } + } + catch (Exception ex) + { + if (ex.GetBaseException() is not OperationCanceledException) + { + Log.Error(ex, "Exception thrown during search result enumeration"); + } + + lock (resultsLock) + { + error = ex; + } + } + + }, cancellationToken); + + var completed = await Task.WhenAny(enumerate, timeout) == enumerate; + await cancelEnumerate.CancelAsync(); + + EventEntity[] takenResults; + Exception? takenError; + lock (resultsLock) + { + takenResults = results.ToArray(); + takenError = error; + } + + string resultSetStatus; + + var reachedLimit = takenResults.Length == limit; + if (reachedLimit) + { + resultSetStatus = $"Showing the most recent {limit} matching event(s):"; + } + else if (takenError != null) + { + if (takenResults.Length == 0) + { + resultSetStatus = $"The search failed. {takenError}"; + } + else + { + resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError.Message}"; + } + } + else if (completed) + { + if (takenResults.Length == 0) + { + resultSetStatus = "No events matched the search expression."; + } + else + { + resultSetStatus = $"Showing all {takenResults.Length} matching event(s)."; + } + } + else + { + if (takenResults.Length == 0) + { + // FUTURE: point to indexes when it's possible to retrieve index info. + resultSetStatus = "The search timed out before any results were identified. " + + "Retry using narrower time ranges."; + } + else + { + resultSetStatus = $"The search timed out after retrieving {takenResults.Length} matching " + + "event(s). Inspect these results, and if more are required, retry using " + + "narrower time ranges."; + } + } + + var responseText = new StringWriter(); + foreach (var result in takenResults) + { + var resultId = session.ImportSearchResult(result); + var serilogEvent = SearchCommand.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); + } + + return new CallToolResult + { + Content = + [ + new TextContentBlock { Text = resultSetStatus }, + new TextContentBlock { Text = responseText.ToString() } + ] + }; + } + + + [McpServerTool(Name = "seq_read_search_result", ReadOnly = true, Title = "Read Full Event Details")] + [Description("Read the full details of an event appearing in `seq_search` results, including all property " + + "values and a complete stack trace (if present). The event is formatted precisely as a Seq syntax literal, " + + "using Seq's native data model.")] + [return: Description("A Seq-native object literal representation of the event data.")] + public Task ReadSearchResultJson( + [Description("The result id from the `seq_search` tool.")] + // ReSharper disable once InconsistentNaming + string result_id) + { + if (!session.TryGetSearchResult(result_id, out var result, out var error)) + { + return Task.FromResult(new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = error + } + ] + }); + } + + var resultText = new StringWriter(); + SeqSyntaxFormatter.FormatAsObjectLiteral(result, resultText); + + return Task.FromResult(new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = resultText.ToString() + } + ] + }); + } +} \ No newline at end of file diff --git a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs index adf1f87d..1362716f 100644 --- a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs +++ b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs @@ -59,7 +59,7 @@ static MessageTemplate GetMessageTemplate(IDictionary propertie m is TextSpan ts) { var text = ts.ToStringValue(); - return new MessageTemplate(new MessageTemplateToken[] {new TextToken(text) }); + return new MessageTemplate([new TextToken(text)]); } return NoMessage; diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index df304e8c..afd80598 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -34,6 +34,7 @@ + @@ -55,4 +56,7 @@ + + + diff --git a/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md b/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md new file mode 100644 index 00000000..0b6dd02b --- /dev/null +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -0,0 +1,416 @@ +--- +name: seq-search-and-query +description: Search and query logs and spans in Seq. Use when interacting with Seq. +license: Apache-2.0 +metadata: + author: Datalust and Contributors +--- + +Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +compute tabular, aggregate results from the same data. + +## 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. The type column uses `?` to indicate properties that +may be undefined for some events. + +| 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. | +| `@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. | +| `@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. | +| `@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`. | +| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@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. | +| `@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. | + +## Type System + +Stored data and intermediate values in expression evaluation are typed dynamically. Values are one of the following types. + +| Type name | Description | Example literals | +|------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **bool** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | + +In expression evaluation, Seq does not perform any type coercion. Functions and operators that receive invalid arguments +evaluate to _undefined_, which is the absence of a value (_undefined_ has roughly the same semantics as `NULL` in standard SQL). + +## Scalar Functions + +These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like count() and distinct() that work with sets of values. + +| Function signature | Description | Result type | +| --- | --- | --- | +| `Arrived(eventId)` | Evaluates to the arrival order encoded in eventId. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. If any argument is null, the result is undefined. | `number` | + +Bucket(number, err) + +Reduce precision by computing the midpoint of the closest logarithmic bucket. If any argument is non-numeric, the result is undefined. + +Result type: number + +Example + +Bucket(3.141592, 0.001) + +Coalesce(arg0, arg1, ...) + +Evaluates to the first defined, non-null argument. If no argument meets this requirement, Coalesce returns the value of its final argument. + +Result type: any +Concat(str0, str1, ...) + +Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is null, the result is undefined. + +Result type: any +Contains(text, substring) + +Evaluates to true if text contains substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. + +Result type: bool +Supports the ci modifier?: Yes +DatePart(datetime, part, offset) + +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 string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is null, the result is undefined. + +Result type: number + +Example + +DatePart(Now(), 'weekday', OffsetIn('Australia/Brisbane', Now())) + +DateTime(str) + +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. If any argument is null, the result is undefined. + +Result type: number +ElementAt(collection, index) + +Access the element of the array or object collection at the index or key index. If any argument is null, the result is undefined. + +Result type: any +Supports the ci modifier?: Yes +EndsWith(text, substring) + +Evaluates to true if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. + +Result type: bool +Supports the ci modifier?: Yes +Every(collection, predicate) + +Evaluates to true if the function predicate evaluates to true for all elements of the array or object collection. If any argument is null, the result is undefined. + +Result type: bool + +Example + +Every(['0.1', '0.1-pre'], |tag| StartsWith(tag, '0.')) + +FromJson(json) + +Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is null, the result is undefined. + +Result type: any +Has(arg) + +Evaluates to true if arg is defined. Otherwise, if arg is undefined, the result is false. + +Result type: bool +IndexOf(text, substring) + +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. If any argument is null, the result is undefined. + +Result type: number +Supports the ci modifier?: Yes +Keys(obj) + +Evaluates to an array containing the keys of the object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. + +Result type: array +LastIndexOf(text, substring) + +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. If any argument is null, the result is undefined. + +Result type: number +Supports the ci modifier?: Yes +Length(arg) + +Evaluates to the length of the string or array arg. If any argument is null, the result is undefined. + +Result type: number +Now() + +Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is null, the result is undefined. + +Result type: number +OffsetIn(timezone, instant) + +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. If any argument is null, the result is undefined. + +Result type: number + +Example + +OffsetIn('Australia/Brisbane', Now()) + +Replace(text, substring, replacement) + +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. If any argument is null, the result is undefined. + +Result type: number +Supports the ci modifier?: Yes +Round(value, places) + +Round value to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. + +Result type: number + +Example + +// Evaluates to 123.5 +Round(123.456, 1) + +Some(collection, predicate) + +Evaluates to true if the function predicate evaluates to true for any element of the array or object collection. If any argument is null, the result is undefined. + +Result type: bool + +Example + +Some(['0.1', '0.1-pre'], |tag| EndsWith(tag, '-pre')) + +StartsWith(text, substring) + +Evaluates to true if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. + +Result type: bool +Supports the ci modifier?: Yes +Substring(str, start, length) + +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. If any argument is undefined, the result is undefined. + +Result type: any +TimeOfDay(datetime, offsetHours) + +Compute the time of day of the date/time datetime in the time zone offset offsetHours. If any argument is non-numeric, the result is undefined. + +Result type: number + +Example + +TimeOfDay(Now(), -7) + +TimeSpan(str) + +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. If any argument is null, the result is undefined. + +Result type: number + +Example + +TimeSpan('1.1:59:59.123') + +ToEventType(str) + +Compute the event type that Seq automatically assigns to @EventType from the message template str. If any argument is null, the result is undefined. + +Result type: any +ToHexString(num) + +Format num as a hexadecimal string, including leading 0x. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. + +Result type: string +ToIsoString(datetime, offset) + +Format datetime as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. + +Result type: string + +Example + +ToIsoString(DateTime('2023-12-20'), 10h) + +ToJson(arg) + +Convert the value arg to JSON. If the argument is undefined, the result is undefined. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. + +Result type: string +ToLower(str) + +Convert string str to lowercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. + +Result type: string +ToNumber(str) + +Parse string str as a number. If any argument is null, the result is undefined. + +Result type: number +TotalMilliseconds(timespan) + +Evaluates to the total number of milliseconds represented by the time span timespan. If timespan is a number, it will be interpreted as containing 100-nanosecond ticks. If timespan is a string, it will be parsed in the same manner as performed by TimeSpan(). If any argument is null, the result is undefined. + +Result type: number + +Example + +TotalMilliseconds(1s) + +ToTimeString(timespan) + +Format timespan as an d.HH:mm:ss.f string. The timespan argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. + +Result type: string + +Example + +ToTimeString(1h) + +ToUpper(str) + +Convert string str to uppercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. + +Result type: string +TypeOf(arg) + +Returns the type of value, either 'object', 'array', 'string', 'number', 'bool', 'null', or 'undefined'. + +Result type: string +Values(obj) + +Evaluates to an array containing the values of the members of object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. + +Result type: array +Operator - + +Subtract one number from another. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator - (prefix) + +Negate a number. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator * + +Multiply two numbers. If any argument is non-numeric, the result is undefined. + +Result type: number +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. + +Result type: number +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. + +Result type: number +Operator ^ + +Raise a number to the specified power. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator + + +Add two numbers. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator < + +Compare two numbers and return true if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +Operator <= + +Compare two numbers and return true if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +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. + +Result type: any +Supports the ci modifier?: Yes +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. + +Result type: any +Supports the ci modifier?: Yes +Operator > + +Compare two numbers and return true if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +Operator >= + +Compare two numbers and return true if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +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. + +Result type: bool +Operator not (prefix) + +Logical NOT. Evaluates to true only 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. + +Result type: bool +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. + +Result type: bool + +## Aggregate Functions (Queries Only) + +## Cheat Sheet + + +## Grammar + +### Expressions + +### Queries + + +## Gotchas + + - 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 don'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 search result + 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 (`ulong` with 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. + \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 7f3a9623..00546bbf 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -19,7 +19,7 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun { var exit = runner.Exec("skills install -a test-agent"); Assert.Equal(0, exit); - Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-query-grammar/SKILL.md"))); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); } finally { From f1784c63c6b6f009f7a29b0714ff6c8dfac85bea Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 12:10:02 +1000 Subject: [PATCH 04/27] More skill WIP --- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 15 + src/SeqCli/Mcp/McpSession.cs | 48 ++ src/SeqCli/Mcp/Tools/Search/SearchTool.cs | 10 + .../Resources/seq-search-and-query/SKILL.md | 443 ++++-------------- 4 files changed, 171 insertions(+), 345 deletions(-) diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index a1ee6147..2a143af0 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -188,4 +188,19 @@ static void WritePropertiesObject(TextWriter output, List mem { WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); } + + public static string MakeIdentifier(string prefixPath, bool optionalPrefix, string propertyName) + { + if (IdentifierRegex().IsMatch(propertyName)) + { + // TODO, exclude keywords here. + if (optionalPrefix) + return propertyName; + return $"{prefixPath}.{propertyName}"; + } + + var sw = new StringWriter(); + WriteValue(sw, propertyName); + return $"{prefixPath}[{sw}]"; + } } diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 61df7c13..5a6988c8 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -4,6 +4,9 @@ using System.Threading; using Seq.Api.Model.Events; using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using SeqCli.Mcp.Formatting; namespace SeqCli.Mcp; @@ -73,4 +76,49 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt return true; } } + + public IEnumerable EnumerateUserPropertyNames() + { + List all; + lock (_sync) + { + all = _eventIdToResult.Values.Select(pair => pair.Item2).ToList(); + } + + var seen = new HashSet(); + foreach (var evt in all) + { + foreach (var property in evt.Properties) + { + foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) + yield return unique; + } + foreach (var property in evt.Scope) + { + foreach (var unique in EnumerateUnique(seen, "@Scope", false, property.Name, property.Value, 1)) + yield return unique; + } + foreach (var property in evt.Resource) + { + foreach (var unique in EnumerateUnique(seen, "@Resource", false, property.Name, property.Value, 1)) + yield return unique; + } + } + } + + static IEnumerable EnumerateUnique(HashSet seen, string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) + { + var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, optionalPrefix, propertyName); + if (seen.Add(name)) + yield return name; + + if (depth < 5 && propertyValue is JObject jo) + { + foreach (var child in jo.Properties()) + { + foreach (var childName in EnumerateUnique(seen, name, false, child.Name, child.Value, depth + 1)) + yield return childName; + } + } + } } \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs index 0080f6db..c7a82ea0 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; @@ -255,4 +256,13 @@ public Task ReadSearchResultJson( ] }); } + + [McpServerTool(Name = "seq_inspect_schema", ReadOnly = true, Title = "Inspect Event Schema")] + [Description("List the user-defined top-level, scope, and resource property names observed on events " + + "so far in this session. Only events retrieved in search results are considered.")] + [return: Description("A list containing Seq syntax-formatted property names.")] + public Task InspectSchema() + { + return Task.FromResult(session.EnumerateUserPropertyNames().OrderBy(n => n).ToArray()); + } } \ 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 0b6dd02b..c9e75974 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -12,8 +12,7 @@ compute tabular, aggregate results from the same data. ## 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. The type column uses `?` to indicate properties that -may be undefined for some events. +`@Start` property. The following built-in properties are supported. | Built in property name | Type | Description | |------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -38,349 +37,103 @@ may be undefined for some events. ## Type System -Stored data and intermediate values in expression evaluation are typed dynamically. Values are one of the following types. - -| Type name | Description | Example literals | -|------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| -| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | -| **bool** | The atoms `true` and `false`. | `true`, `false` | -| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | -| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | -| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | - -In expression evaluation, Seq does not perform any type coercion. Functions and operators that receive invalid arguments -evaluate to _undefined_, which is the absence of a value (_undefined_ has roughly the same semantics as `NULL` in standard SQL). - -## Scalar Functions - -These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like count() and distinct() that work with sets of values. - -| Function signature | Description | Result type | -| --- | --- | --- | -| `Arrived(eventId)` | Evaluates to the arrival order encoded in eventId. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. If any argument is null, the result is undefined. | `number` | - -Bucket(number, err) - -Reduce precision by computing the midpoint of the closest logarithmic bucket. If any argument is non-numeric, the result is undefined. - -Result type: number - -Example - -Bucket(3.141592, 0.001) - -Coalesce(arg0, arg1, ...) - -Evaluates to the first defined, non-null argument. If no argument meets this requirement, Coalesce returns the value of its final argument. - -Result type: any -Concat(str0, str1, ...) - -Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is null, the result is undefined. - -Result type: any -Contains(text, substring) - -Evaluates to true if text contains substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. - -Result type: bool -Supports the ci modifier?: Yes -DatePart(datetime, part, offset) - -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 string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is null, the result is undefined. - -Result type: number - -Example - -DatePart(Now(), 'weekday', OffsetIn('Australia/Brisbane', Now())) - -DateTime(str) - -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. If any argument is null, the result is undefined. - -Result type: number -ElementAt(collection, index) - -Access the element of the array or object collection at the index or key index. If any argument is null, the result is undefined. - -Result type: any -Supports the ci modifier?: Yes -EndsWith(text, substring) - -Evaluates to true if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. - -Result type: bool -Supports the ci modifier?: Yes -Every(collection, predicate) - -Evaluates to true if the function predicate evaluates to true for all elements of the array or object collection. If any argument is null, the result is undefined. - -Result type: bool - -Example - -Every(['0.1', '0.1-pre'], |tag| StartsWith(tag, '0.')) - -FromJson(json) - -Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is null, the result is undefined. - -Result type: any -Has(arg) - -Evaluates to true if arg is defined. Otherwise, if arg is undefined, the result is false. - -Result type: bool -IndexOf(text, substring) - -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. If any argument is null, the result is undefined. - -Result type: number -Supports the ci modifier?: Yes -Keys(obj) - -Evaluates to an array containing the keys of the object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. - -Result type: array -LastIndexOf(text, substring) - -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. If any argument is null, the result is undefined. - -Result type: number -Supports the ci modifier?: Yes -Length(arg) - -Evaluates to the length of the string or array arg. If any argument is null, the result is undefined. - -Result type: number -Now() - -Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is null, the result is undefined. - -Result type: number -OffsetIn(timezone, instant) - -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. If any argument is null, the result is undefined. - -Result type: number - -Example - -OffsetIn('Australia/Brisbane', Now()) - -Replace(text, substring, replacement) - -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. If any argument is null, the result is undefined. - -Result type: number -Supports the ci modifier?: Yes -Round(value, places) - -Round value to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. - -Result type: number - -Example - -// Evaluates to 123.5 -Round(123.456, 1) - -Some(collection, predicate) - -Evaluates to true if the function predicate evaluates to true for any element of the array or object collection. If any argument is null, the result is undefined. - -Result type: bool - -Example - -Some(['0.1', '0.1-pre'], |tag| EndsWith(tag, '-pre')) - -StartsWith(text, substring) - -Evaluates to true if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. - -Result type: bool -Supports the ci modifier?: Yes -Substring(str, start, length) - -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. If any argument is undefined, the result is undefined. - -Result type: any -TimeOfDay(datetime, offsetHours) - -Compute the time of day of the date/time datetime in the time zone offset offsetHours. If any argument is non-numeric, the result is undefined. - -Result type: number - -Example - -TimeOfDay(Now(), -7) - -TimeSpan(str) - -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. If any argument is null, the result is undefined. - -Result type: number - -Example - -TimeSpan('1.1:59:59.123') - -ToEventType(str) - -Compute the event type that Seq automatically assigns to @EventType from the message template str. If any argument is null, the result is undefined. - -Result type: any -ToHexString(num) - -Format num as a hexadecimal string, including leading 0x. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. - -Result type: string -ToIsoString(datetime, offset) - -Format datetime as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. - -Result type: string - -Example - -ToIsoString(DateTime('2023-12-20'), 10h) - -ToJson(arg) - -Convert the value arg to JSON. If the argument is undefined, the result is undefined. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. - -Result type: string -ToLower(str) - -Convert string str to lowercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. - -Result type: string -ToNumber(str) - -Parse string str as a number. If any argument is null, the result is undefined. - -Result type: number -TotalMilliseconds(timespan) - -Evaluates to the total number of milliseconds represented by the time span timespan. If timespan is a number, it will be interpreted as containing 100-nanosecond ticks. If timespan is a string, it will be parsed in the same manner as performed by TimeSpan(). If any argument is null, the result is undefined. - -Result type: number - -Example - -TotalMilliseconds(1s) - -ToTimeString(timespan) - -Format timespan as an d.HH:mm:ss.f string. The timespan argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. - -Result type: string - -Example - -ToTimeString(1h) - -ToUpper(str) - -Convert string str to uppercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. - -Result type: string -TypeOf(arg) - -Returns the type of value, either 'object', 'array', 'string', 'number', 'bool', 'null', or 'undefined'. - -Result type: string -Values(obj) - -Evaluates to an array containing the values of the members of object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. - -Result type: array -Operator - - -Subtract one number from another. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator - (prefix) - -Negate a number. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator * - -Multiply two numbers. If any argument is non-numeric, the result is undefined. - -Result type: number -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. - -Result type: number -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. - -Result type: number -Operator ^ - -Raise a number to the specified power. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator + - -Add two numbers. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator < - -Compare two numbers and return true if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -Operator <= - -Compare two numbers and return true if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -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. - -Result type: any -Supports the ci modifier?: Yes -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. - -Result type: any -Supports the ci modifier?: Yes -Operator > - -Compare two numbers and return true if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -Operator >= - -Compare two numbers and return true if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -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. - -Result type: bool -Operator not (prefix) - -Logical NOT. Evaluates to true only 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. - -Result type: bool -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. - -Result type: bool - -## Aggregate Functions (Queries Only) +Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. + +| Type name | Description | Example literals | +|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **boolean** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | + +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). + +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`. + +## Scalar Functions and Operators + +These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. + +| Function signature | Description | Result type | +|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | `number` | +| `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. | `number` | +| `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. | `any` | +| `Concat(str0, str1, ...)` | Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is `null`, the result is undefined. | `any` | +| `Contains(text, substring)` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | +| `DatePart(datetime, part, offset)` | 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 string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is `null`, the result is undefined. | `number` | +| `DateTime(str)` | 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. If any argument is `null`, the result is undefined. | `number` | +| `ElementAt(collection, index)` | Access the element of the array or object collection at the index or key index. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `any` | +| `EndsWith(text, substring)` | Evaluates to `true` if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | +| `Every(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | +| `FromJson(json)` | Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is `null`, the result is undefined. | `any` | +| `Has(arg)` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | `bool` | +| `IndexOf(text, substring)` | 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`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | +| `Keys(obj)` | Evaluates to an array containing the keys of the object `obj`. The result is undefined if `obj` is not an object. If any argument is `null`, the result is undefined. | `array` | +| `LastIndexOf(text, substring)` | 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`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | +| `Length(arg)` | Evaluates to the length of the string or array `arg`. If any argument is `null`, the result is undefined. | `number` | +| `Now()` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is `null`, the result is undefined. | `number` | +| `OffsetIn(timezone, instant)` | 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. If any argument is `null`, the result is undefined. | `number` | +| `Replace(text, substring, replacement)` | 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. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | +| `Round(value, places)` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. | `number` | +| `Some(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | +| `StartsWith(text, substring)` | Evaluates to `true` if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | +| `Substring(str, start, length)` | 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. If any argument is undefined, the result is undefined. | `any` | +| `TimeOfDay(datetime, offsetHours)` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. If any argument is non-numeric, the result is undefined. | `number` | +| `TimeSpan(str)` | 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. If any argument is `null`, the result is undefined. | `number` | +| `ToEventType(str)` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. If any argument is `null`, the result is undefined. | `any` | +| `ToHexString(num)` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. | `string` | +| `ToIsoString(datetime, offset)` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. | `string` | +| `ToJson(arg)` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. | `string` | +| `ToLower(str)` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if `str` is not a string. | `string` | +| `ToNumber(str)` | Parse string str as a number. If any argument is `null`, the result is undefined. | `number` | +| `TotalMilliseconds(timespan)` | Evaluates to the total number of milliseconds represented by the time span timespan. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. If any argument is `null`, the result is undefined. | `number` | +| `ToTimeString(timespan)` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. | `string` | +| `ToUpper(str)` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if str is not a string. | `string` | +| `TypeOf(arg)` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'bool'`, `'null'`, or `'undefined'`. | `string` | +| `Values(obj)` | Evaluates to an array containing the values of the members of object `obj`. The result is undefined if `obj` is not an object. | `array` | +| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | `number` | +| 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. | `number` | +| 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. | `number` | +| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| 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. | `any` | +| 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. | `any` | +| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| 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. | `bool` | +| 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. | `bool` | +| 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. | `bool` | + +## Aggregate Functions + +`select` queries (see grammar below) have access to the following aggregate functions. + +| Aggregate function signature | Description | Example | | Result type | +|------------------------------|----------------------------------------------------------------------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `all(expr)` | Given an expression, return `true` if the expression is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr)` | Given an expression, return `true` if the expression is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr, n)` | Given an expression, compute the last N values that appear for that expression. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property)` | Given a property name, computes the number of events that have a non-null value for that property. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr)` | Given a property name or expression, computes the set of distinct values for that expression. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr)` | Given an expression, returns the value of that expression applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr)` | Given an expression, returns the value of that expression applied to the last events in the target range. | `last(Elapsed)` | +| `interval()` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr)` | Given a numeric expression or property name, computes the smallest value for that expression. | `min(Elapsed / 1000)` | +| `max(expr)` | Given a numeric expression or property name, computes the largest value for that expression. | `max(Elapsed / 1000)` | +| `mean(expr)` | Computes the arithmetic mean (average) of a numeric expression or property, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| `percentile(expr, p [, err])` | Given an expression and a percentage `p`, calculates the value of the expression at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 [, err = 0.01])` | +| `sum(expr)` | Given an expression or property name, calculates the sum of that value. Non-numeric results are ignored. | `sum(ItemsOrdered)` | +| `top(expr, n)` | Select the first N values of an expression. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | ## Cheat Sheet From 588230d55d4c58342658ae427b78d2ff0bd4fc6e Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 13:38:46 +1000 Subject: [PATCH 05/27] More skill and tool WIP --- .claude/skills/seq-search-and-query/SKILL.md | 296 ++++++++++++++++++ src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 2 +- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 10 +- src/SeqCli/Mcp/McpSession.cs | 4 +- ...earchTool.cs => SearchAndQueryToolType.cs} | 32 +- .../Resources/seq-search-and-query/SKILL.md | 283 ++++++++++++----- src/SeqCli/Skills/SkillInstaller.cs | 2 +- 7 files changed, 545 insertions(+), 84 deletions(-) rename src/SeqCli/Mcp/Tools/Search/{SearchTool.cs => SearchAndQueryToolType.cs} (89%) diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index f6467791..0b425248 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -6,3 +6,299 @@ metadata: author: Datalust and Contributors --- +Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +compute tabular, aggregate results from the same data. + +> This skill does not currently cover interactions with metrics (the `series` storage object). + +## 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. + +| 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. | +| `@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. | +| `@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`. | +| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@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. | +| `@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. | + +## Type System + +Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. + +| Type name | Description | Example literals | +|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **boolean** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | + +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). + +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`. + +## Scalar Functions and Operators + +These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. + +| Function signature | Description | +|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `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 `-` (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 `^` | 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 numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| 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 numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| 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 `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. | + +## Aggregate Functions + +`select` queries (see grammar below) have access to the following aggregate functions. + +| Aggregate function signature | Description | Example | +|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| +| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | +| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | +| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | +| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | +| `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 + +### Base + +```ebnf +identifier = ( letter | '_' ) , { letter | digit | '_' } ; +built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +letter = ? any Unicode letter ? ; +digit = ? any Unicode digit ? ; +string_literal = "'" , { string_char } , "'" ; +string_char = "''" | ? any character except single quote ? ; +number = natural , [ '.' , natural ] ; +hex_number = '0x' , hex_digit , { hex_digit } ; +natural = digit , { digit } ; +hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' + | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; +duration = { natural , time_unit }- ; +time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; +regular_expression = '/' , { regex_char } , '/' ; +regex_char = '\/' | ? any character except '/' ? ; +``` + +### Expression + +``` +Expr = Disjunction ; +Disjunction = Conjunction , { 'or' , Conjunction } ; +Conjunction = Comparison , { 'and' , Comparison } ; +Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; +comparison_op = 'not' , 'like' + | 'like' + | 'not' , 'in' + | 'in' + | '<=' | '<>' | '<' + | '>=' | '>' + | '=' ; +Comparand = Term , { ( '+' | '-' ) , Term } ; +Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; +InnerTerm = Operand , { '^' , Operand } ; + +Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; +unary_op = '-' | 'not' ; +null_test = 'null' | 'not' , 'null' ; +Path = Factor , { path_step } ; +path_step = '.' , identifier + | '[' , ( wildcard | Expr ) , ']' ; +wildcard = '?' | '*' ; +Factor = '(' , Expr , ')' + | Item ; +Item = Property + | Literal + | Function + | ArrayLiteral + | ObjectLiteral + | Conditional + | Block + | Lambda + | Variable ; +Property = built_in_identifier + | identifier ; (* when not followed by '(' *) +Literal = string_literal + | number + | hex_number + | duration + | regular_expression + | 'true' + | 'false' + | 'null' ; +Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; +function_name = identifier + | 'and' | 'not' | 'or' ; +arg_list = '*' (* only valid for count(*) *) + | [ Expr , { ',' , Expr } ] ; +ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; +ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; +ObjectMember = ( identifier | string_literal ) , ':' , Expr ; +Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; +Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; +Binding = identifier , ':' , Expr ; +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 + +```ebnf +Query = [ ExplainClause ] + SelectClause + [ IntoClause ] + [ FromClause ] + [ WhereClause ] + [ GroupByClause ] + [ HavingClause ] + [ OrderByClause ] + [ LimitClause ] + [ ForClause ] ; +ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; +SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; +SelectColumn = '*' + | Expr , [ 'as' , identifier ] ; +IntoClause = 'into' , variable ; +FromClause = 'from' , source , { LateralJoin } ; +source = 'stream' | 'series' ; +LateralJoin = 'lateral' , Expr , 'as' , identifier ; +WhereClause = 'where' , Expr ; +GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; +Grouping = TimeGrouping + | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; +TimeGrouping = 'time' , '(' , duration , ')' ; +HavingClause = 'having' , Expr ; +OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; +Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; +LimitClause = 'limit' , natural ; +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. + +## Search Expression Examples + +| Example | Effect | +|---|-----------------------------------------------------| +| `@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%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | + +## Gotchas + + - 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 don'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 search result + 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 (`ulong` with 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. + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 5bcb900b..cf8ac5b4 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -50,7 +50,7 @@ protected override async Task Run() .AddMcpServer() .WithStdioServerTransport() .WithTools([ - typeof(SearchTool) + typeof(SearchAndQueryToolType) ]); await builder.Build().RunAsync(); diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 2a143af0..2e7c0266 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -19,6 +19,13 @@ static partial class SeqSyntaxFormatter [GeneratedRegex("[_a-zA-Z][_a-zA-Z0-9]*")] private static partial Regex IdentifierRegex(); + + static readonly HashSet Keywords = new(StringComparer.OrdinalIgnoreCase) + { + "and", "ci", "else", "false", "if", "in", "is", "let", "like", "not", "null", "or", "then", "true", + "analyze", "as", "asc", "by", "desc", "explain", "for", "from", "group", "having", "into", "lateral", + "limit", "lower", "order", "select", "where" + }; public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) { @@ -193,8 +200,7 @@ public static string MakeIdentifier(string prefixPath, bool optionalPrefix, stri { if (IdentifierRegex().IsMatch(propertyName)) { - // TODO, exclude keywords here. - if (optionalPrefix) + if (optionalPrefix && !Keywords.Contains(propertyName)) return propertyName; return $"{prefixPath}.{propertyName}"; } diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 5a6988c8..0a1e6a60 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -77,7 +77,7 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt } } - public IEnumerable EnumerateUserPropertyNames() + public IEnumerable EnumerateUserPropertyNames(CancellationToken cancellationToken) { List all; lock (_sync) @@ -88,6 +88,8 @@ public IEnumerable EnumerateUserPropertyNames() var seen = new HashSet(); foreach (var evt in all) { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var property in evt.Properties) { foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs similarity index 89% rename from src/SeqCli/Mcp/Tools/Search/SearchTool.cs rename to src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index c7a82ea0..1be04f11 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -23,7 +23,7 @@ namespace SeqCli.Mcp.Tools.Search; [McpServerToolType] -class SearchTool(McpSession session, SeqConnection connection) +class SearchAndQueryToolType(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( @@ -36,7 +36,7 @@ class SearchTool(McpSession session, SeqConnection connection) "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + "skill when calling this tool.")] [return: Description("Search results and status information.")] - public async Task SearchEvents( + public async Task SearchEventsAsync( [Description("The maximum number of events to return.")] [Range(1, 1000)] int limit, @@ -222,7 +222,7 @@ public async Task SearchEvents( "values and a complete stack trace (if present). The event is formatted precisely as a Seq syntax literal, " + "using Seq's native data model.")] [return: Description("A Seq-native object literal representation of the event data.")] - public Task ReadSearchResultJson( + public Task ReadSearchResultJsonAsync( [Description("The result id from the `seq_search` tool.")] // ReSharper disable once InconsistentNaming string result_id) @@ -261,8 +261,30 @@ public Task ReadSearchResultJson( [Description("List the user-defined top-level, scope, and resource property names observed on events " + "so far in this session. Only events retrieved in search results are considered.")] [return: Description("A list containing Seq syntax-formatted property names.")] - public Task InspectSchema() + public Task InspectSchemaAsync(CancellationToken cancellationToken) { - return Task.FromResult(session.EnumerateUserPropertyNames().OrderBy(n => n).ToArray()); + return Task.FromResult(session.EnumerateUserPropertyNames(cancellationToken).OrderBy(n => n).ToArray()); + } + + [McpServerTool(Name = "seq_query", ReadOnly = true, Title = "Evaluate a Query over Logs, Spans, or Metric Samples")] + [Description("Evaluate a Seq query, producing tabular results. Use the `seq-search-and-query` " + + "skill when calling this tool.")] + [return: Description("Query results and status information.")] + public async Task QueryAsync( + [Description("A Seq query language query.")] + string query, + CancellationToken cancellationToken) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = "The query tool is not implemented." + } + ] + }; } } \ 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 c9e75974..0b425248 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -9,6 +9,8 @@ metadata: Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to compute tabular, aggregate results from the same data. +> This skill does not currently cover interactions with metrics (the `series` storage object). + ## Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the @@ -18,6 +20,7 @@ All events stored in Seq use the same data model. Spans are only distinguished f |------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `@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. | | `@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. | @@ -59,91 +62,221 @@ The synthetic type name `any` is used as an alias for `null | boolean | number | These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. -| Function signature | Description | Result type | -|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | `number` | -| `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. | `number` | -| `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. | `any` | -| `Concat(str0, str1, ...)` | Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is `null`, the result is undefined. | `any` | -| `Contains(text, substring)` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | -| `DatePart(datetime, part, offset)` | 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 string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is `null`, the result is undefined. | `number` | -| `DateTime(str)` | 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. If any argument is `null`, the result is undefined. | `number` | -| `ElementAt(collection, index)` | Access the element of the array or object collection at the index or key index. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `any` | -| `EndsWith(text, substring)` | Evaluates to `true` if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | -| `Every(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | -| `FromJson(json)` | Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is `null`, the result is undefined. | `any` | -| `Has(arg)` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | `bool` | -| `IndexOf(text, substring)` | 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`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | -| `Keys(obj)` | Evaluates to an array containing the keys of the object `obj`. The result is undefined if `obj` is not an object. If any argument is `null`, the result is undefined. | `array` | -| `LastIndexOf(text, substring)` | 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`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | -| `Length(arg)` | Evaluates to the length of the string or array `arg`. If any argument is `null`, the result is undefined. | `number` | -| `Now()` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is `null`, the result is undefined. | `number` | -| `OffsetIn(timezone, instant)` | 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. If any argument is `null`, the result is undefined. | `number` | -| `Replace(text, substring, replacement)` | 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. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | -| `Round(value, places)` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. | `number` | -| `Some(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | -| `StartsWith(text, substring)` | Evaluates to `true` if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | -| `Substring(str, start, length)` | 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. If any argument is undefined, the result is undefined. | `any` | -| `TimeOfDay(datetime, offsetHours)` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. If any argument is non-numeric, the result is undefined. | `number` | -| `TimeSpan(str)` | 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. If any argument is `null`, the result is undefined. | `number` | -| `ToEventType(str)` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. If any argument is `null`, the result is undefined. | `any` | -| `ToHexString(num)` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. | `string` | -| `ToIsoString(datetime, offset)` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. | `string` | -| `ToJson(arg)` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. | `string` | -| `ToLower(str)` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if `str` is not a string. | `string` | -| `ToNumber(str)` | Parse string str as a number. If any argument is `null`, the result is undefined. | `number` | -| `TotalMilliseconds(timespan)` | Evaluates to the total number of milliseconds represented by the time span timespan. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. If any argument is `null`, the result is undefined. | `number` | -| `ToTimeString(timespan)` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. | `string` | -| `ToUpper(str)` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if str is not a string. | `string` | -| `TypeOf(arg)` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'bool'`, `'null'`, or `'undefined'`. | `string` | -| `Values(obj)` | Evaluates to an array containing the values of the members of object `obj`. The result is undefined if `obj` is not an object. | `array` | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | `number` | -| 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. | `number` | -| 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. | `number` | -| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| 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. | `any` | -| 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. | `any` | -| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| 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. | `bool` | -| 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. | `bool` | -| 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. | `bool` | +| Function signature | Description | +|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `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 `-` (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 `^` | 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 numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| 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 numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| 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 `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. | ## Aggregate Functions `select` queries (see grammar below) have access to the following aggregate functions. -| Aggregate function signature | Description | Example | | Result type | -|------------------------------|----------------------------------------------------------------------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| -| `all(expr)` | Given an expression, return `true` if the expression is `true` for all events in the stream. | `all(@Level = 'Error')` | -| `any(expr)` | Given an expression, return `true` if the expression is `true` for any event in the stream. | `any(@Level = 'Error')` | -| `bottom(expr, n)` | Given an expression, compute the last N values that appear for that expression. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | -| `count(property)` | Given a property name, computes the number of events that have a non-null value for that property. The special property name `*` can be used to count all events. | `count(*)` | -| `distinct(expr)` | Given a property name or expression, computes the set of distinct values for that expression. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | -| `first(expr)` | Given an expression, returns the value of that expression applied to the first events in the target range. | `first(Elapsed)` | -| `last(expr)` | Given an expression, returns the value of that expression applied to the last events in the target range. | `last(Elapsed)` | -| `interval()` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | -| `min(expr)` | Given a numeric expression or property name, computes the smallest value for that expression. | `min(Elapsed / 1000)` | -| `max(expr)` | Given a numeric expression or property name, computes the largest value for that expression. | `max(Elapsed / 1000)` | -| `mean(expr)` | Computes the arithmetic mean (average) of a numeric expression or property, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | -| `percentile(expr, p [, err])` | Given an expression and a percentage `p`, calculates the value of the expression at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 [, err = 0.01])` | -| `sum(expr)` | Given an expression or property name, calculates the sum of that value. Non-numeric results are ignored. | `sum(ItemsOrdered)` | -| `top(expr, n)` | Select the first N values of an expression. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | - -## Cheat Sheet - +| Aggregate function signature | Description | Example | +|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| +| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | +| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | +| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | +| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | +| `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 -### Expressions +### Base + +```ebnf +identifier = ( letter | '_' ) , { letter | digit | '_' } ; +built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +letter = ? any Unicode letter ? ; +digit = ? any Unicode digit ? ; +string_literal = "'" , { string_char } , "'" ; +string_char = "''" | ? any character except single quote ? ; +number = natural , [ '.' , natural ] ; +hex_number = '0x' , hex_digit , { hex_digit } ; +natural = digit , { digit } ; +hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' + | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; +duration = { natural , time_unit }- ; +time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; +regular_expression = '/' , { regex_char } , '/' ; +regex_char = '\/' | ? any character except '/' ? ; +``` + +### Expression + +``` +Expr = Disjunction ; +Disjunction = Conjunction , { 'or' , Conjunction } ; +Conjunction = Comparison , { 'and' , Comparison } ; +Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; +comparison_op = 'not' , 'like' + | 'like' + | 'not' , 'in' + | 'in' + | '<=' | '<>' | '<' + | '>=' | '>' + | '=' ; +Comparand = Term , { ( '+' | '-' ) , Term } ; +Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; +InnerTerm = Operand , { '^' , Operand } ; + +Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; +unary_op = '-' | 'not' ; +null_test = 'null' | 'not' , 'null' ; +Path = Factor , { path_step } ; +path_step = '.' , identifier + | '[' , ( wildcard | Expr ) , ']' ; +wildcard = '?' | '*' ; +Factor = '(' , Expr , ')' + | Item ; +Item = Property + | Literal + | Function + | ArrayLiteral + | ObjectLiteral + | Conditional + | Block + | Lambda + | Variable ; +Property = built_in_identifier + | identifier ; (* when not followed by '(' *) +Literal = string_literal + | number + | hex_number + | duration + | regular_expression + | 'true' + | 'false' + | 'null' ; +Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; +function_name = identifier + | 'and' | 'not' | 'or' ; +arg_list = '*' (* only valid for count(*) *) + | [ Expr , { ',' , Expr } ] ; +ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; +ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; +ObjectMember = ( identifier | string_literal ) , ':' , Expr ; +Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; +Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; +Binding = identifier , ':' , Expr ; +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 +```ebnf +Query = [ ExplainClause ] + SelectClause + [ IntoClause ] + [ FromClause ] + [ WhereClause ] + [ GroupByClause ] + [ HavingClause ] + [ OrderByClause ] + [ LimitClause ] + [ ForClause ] ; +ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; +SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; +SelectColumn = '*' + | Expr , [ 'as' , identifier ] ; +IntoClause = 'into' , variable ; +FromClause = 'from' , source , { LateralJoin } ; +source = 'stream' | 'series' ; +LateralJoin = 'lateral' , Expr , 'as' , identifier ; +WhereClause = 'where' , Expr ; +GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; +Grouping = TimeGrouping + | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; +TimeGrouping = 'time' , '(' , duration , ')' ; +HavingClause = 'having' , Expr ; +OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; +Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; +LimitClause = 'limit' , natural ; +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. + +## Search Expression Examples + +| Example | Effect | +|---|-----------------------------------------------------| +| `@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%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | ## Gotchas @@ -166,4 +299,6 @@ These built-in functions and operators work with individual values. See Aggregat - 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. \ No newline at end of file diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 90736448..123b7ea2 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -41,7 +41,7 @@ static void CopyFilesRecursive(string source, string destination) foreach (var file in Directory.EnumerateFiles(source)) { - File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); } foreach (var directory in Directory.EnumerateDirectories(source)) From 229bee3160619a19f0c1866eee04e78566c0a8c6 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 17:08:51 +1000 Subject: [PATCH 06/27] More skill and tool WIP --- .claude/skills/seq-search-and-query/SKILL.md | 23 +- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 54 +++- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 5 +- src/SeqCli/Mcp/McpSession.cs | 16 +- .../Tools/Search/SearchAndQueryToolType.cs | 273 ++++++++++++++---- .../Resources/seq-search-and-query/SKILL.md | 23 +- src/SeqCli/Syntax/DurationMoniker.cs | 2 +- test/SeqCli.Tests/Mcp/McpSessionTests.cs | 16 + 8 files changed, 310 insertions(+), 102 deletions(-) create mode 100644 test/SeqCli.Tests/Mcp/McpSessionTests.cs diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index 0b425248..79bea87a 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -6,7 +6,7 @@ metadata: author: Datalust and Contributors --- -Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +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. > This skill does not currently cover interactions with metrics (the `series` storage object). @@ -268,15 +268,17 @@ ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains metric samples. -## Search Expression Examples +## Expression Examples -| Example | Effect | -|---|-----------------------------------------------------| -| `@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). | +| Example | Effect | +|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `@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%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | ## Gotchas @@ -301,4 +303,7 @@ metric samples. - 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. - \ No newline at end of file + - 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`. + - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index cf8ac5b4..eb720146 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Threading.Tasks; using Autofac.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; @@ -30,30 +31,55 @@ class RunCommand: Command { readonly ConnectionFeature _connection; readonly StoragePathFeature _storagePath; + bool _debug; public RunCommand() { _connection = Enable(); _storagePath = Enable(); + Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection.", + _ => _debug = true); } protected override async Task Run() { var config = RuntimeConfigurationLoader.Load(_storagePath); - - var builder = Host.CreateApplicationBuilder(); - builder.ConfigureContainer(new AutofacServiceProviderFactory()); - builder.Services.AddSerilog(); - builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); - builder.Services.AddSingleton(); - builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithTools([ - typeof(SearchAndQueryToolType) - ]); - - await builder.Build().RunAsync(); + + if (_debug) + { + Log.Logger = new LoggerConfiguration() + .Enrich.WithProperty("Application", "seqcli") + .WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector())) + .CreateLogger(); + + Log.Information("seqcli MCP server starting up"); + } + + try + { + var builder = Host.CreateApplicationBuilder(); + builder.ConfigureContainer(new AutofacServiceProviderFactory()); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); + builder.Services.AddSingleton(); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools([ + typeof(SearchAndQueryToolType) + ]); + + await builder.Build().RunAsync(); + } + catch (Exception ex) + { + Log.Fatal(ex, "Unhandled exception"); + return 1; + } + finally + { + await Log.CloseAndFlushAsync(); + } return 0; } } \ No newline at end of file diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 2e7c0266..f68d39c8 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -59,7 +59,7 @@ static uint ParseEventType(string dollarPrefixedHex) static string ReconstructTemplate(IEnumerable tokens) { - return string.Concat(tokens.Select(t => t.RawText)); + return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); } static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) @@ -99,7 +99,7 @@ static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(st output.Write('}'); } - static void WriteValue(TextWriter output, object? value) + public static void WriteValue(TextWriter output, object? value) { if (value == UndefinedValue) { @@ -147,6 +147,7 @@ or byte or ushort or uint or ulong or UInt128 or if (value is DateTime dt) { output.Write($"DateTime('{dt:O}')"); + return; } if (value is JArray ja) diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 0a1e6a60..099c0d36 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -31,14 +31,14 @@ public string ImportSearchResult(EventEntity evt) } } - static string FormatResultId(int resultId) + internal static string FormatResultId(int resultId) { - return "E" + resultId.ToString("X5"); + return "R" + resultId.ToString("X5"); } - static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) + internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) { - if (!formatted.StartsWith('E') || !int.TryParse(formatted.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) { resultId = null; return false; @@ -54,7 +54,7 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt { result = null; error = - "The result id is not correctly formatted; result ids are strings beginning with `E`, followed by a short character string."; + "The result id is not correctly formatted; result ids are strings beginning with `R`, followed by a short character string."; return false; } @@ -90,17 +90,17 @@ public IEnumerable EnumerateUserPropertyNames(CancellationToken cancella { cancellationToken.ThrowIfCancellationRequested(); - foreach (var property in evt.Properties) + foreach (var property in evt.Properties ?? []) { foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) yield return unique; } - foreach (var property in evt.Scope) + foreach (var property in evt.Scope ?? []) { foreach (var unique in EnumerateUnique(seen, "@Scope", false, property.Name, property.Value, 1)) yield return unique; } - foreach (var property in evt.Resource) + foreach (var property in evt.Resource ?? []) { foreach (var unique in EnumerateUnique(seen, "@Resource", false, property.Name, property.Value, 1)) yield return unique; diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 1be04f11..8f2f16fd 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -2,13 +2,19 @@ using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Newtonsoft.Json; using Seq.Api; +using Seq.Api.Client; +using Seq.Api.Model.Data; using Seq.Api.Model.Events; using Seq.Api.Model.Expressions; using Seq.Syntax.Templates; @@ -27,12 +33,19 @@ class SearchAndQueryToolType(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( - $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{Substring(ToString(@x), 0, 140)}}" + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}\n{{#end}}" ); + static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + Culture = CultureInfo.InvariantCulture, + FloatParseHandling = FloatParseHandling.Decimal, + }); + [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + - "a `result_id` of the form `E..` which is valid in the current MCP session. Individual events can be " + + "a `result_id` of the form `R#####` which is valid in the current MCP session. Individual events can be " + "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + "skill when calling this tool.")] [return: Description("Search results and status information.")] @@ -42,27 +55,16 @@ public async Task SearchEventsAsync( int limit, [Description("A Seq search expression evaluated over event properties.")] string? predicate = null, - [Description("The search timeout, in seconds; the default is 45.")] - [Range(5, 180)] - int timeoutSeconds = 45, CancellationToken cancellationToken = default) { if (!string.IsNullOrWhiteSpace(predicate)) { - if (!predicate.Contains("@Timestamp") || predicate.Contains("@Id")) + if (!predicate.Contains("@Timestamp") && + !predicate.Contains("@Id") && + !predicate.Contains("@TraceId")) { - return new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = "The predicate doesn't adequately constrain the search range (by `@Timestamp` or `@Id`). " + - "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", - } - ] - }; + return SimpleTextResult("The predicate doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", isError: true); } ExpressionPart strict; @@ -90,24 +92,15 @@ public async Task SearchEventsAsync( } if (strict.MatchedAsText) { - return new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = $"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}" - } - ], - }; + return SimpleTextResult($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", + isError: true); } } var resultsLock = new Lock(); - Exception? error = null; + string? error = null; var results = new List(); - var timeout = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), cancellationToken); + var timeout = Task.Delay(TimeSpan.FromSeconds(45), cancellationToken); using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var cancelEnumerateToken = cancelEnumerate.Token; var enumerate = Task.Run(async () => @@ -117,6 +110,7 @@ public async Task SearchEventsAsync( await foreach (var evt in connection.Events.EnumerateAsync( filter: predicate, count: limit, + render: true, cancellationToken: cancelEnumerateToken)) { lock (resultsLock) @@ -134,7 +128,7 @@ public async Task SearchEventsAsync( lock (resultsLock) { - error = ex; + error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); } } @@ -144,7 +138,7 @@ public async Task SearchEventsAsync( await cancelEnumerate.CancelAsync(); EventEntity[] takenResults; - Exception? takenError; + string? takenError; lock (resultsLock) { takenResults = results.ToArray(); @@ -166,7 +160,7 @@ public async Task SearchEventsAsync( } else { - resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError.Message}"; + resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError}"; } } else if (completed) @@ -229,37 +223,18 @@ public Task ReadSearchResultJsonAsync( { if (!session.TryGetSearchResult(result_id, out var result, out var error)) { - return Task.FromResult(new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = error - } - ] - }); + return Task.FromResult(SimpleTextResult(error, isError: true)); } var resultText = new StringWriter(); SeqSyntaxFormatter.FormatAsObjectLiteral(result, resultText); - return Task.FromResult(new CallToolResult - { - Content = - [ - new TextContentBlock - { - Text = resultText.ToString() - } - ] - }); + return Task.FromResult(SimpleTextResult(resultText.ToString())); } - [McpServerTool(Name = "seq_inspect_schema", ReadOnly = true, Title = "Inspect Event Schema")] + [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 " + - "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.")] [return: Description("A list containing Seq syntax-formatted property names.")] public Task InspectSchemaAsync(CancellationToken cancellationToken) { @@ -274,15 +249,195 @@ public async Task QueryAsync( [Description("A Seq query language query.")] string query, CancellationToken cancellationToken) + { + if (!query.Contains("@Timestamp") && + !query.Contains("@Id") && + !query.Contains("@TraceId")) + { + return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); + } + + QueryResultPart result; + try + { + var request = new HttpRequestMessage + { + RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query)), + Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") + }; + var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); + result = Serializer.Deserialize( + new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; + } + catch (Exception ex) + { + if (ex.GetBaseException() is not OperationCanceledException) + { + Log.Error(ex, "Exception thrown during query execution"); + } + + var error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); + return SimpleTextResult($"The search failed. {error}", isError: true); + } + + if (result.Error != null) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = $"The query could not be executed. {result.Error}" + }, + new TextContentBlock + { + Text = string.Join(" ", result.Reasons) + }, + new TextContentBlock + { + Text = result.Suggestion != null ? $"Did you mean: {result.Suggestion}?" : "" + } + ] + }; + } + + var output = new StringWriter(); + var first = true; + FlattenResult(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(); + output.WriteLine(); + } + else + { + var firstCol = true; + foreach (var value in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + SeqSyntaxFormatter.WriteValue(output, value); + } + output.WriteLine(); + } + }); + + return new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = output.ToString() + } + ] + }; + } + + static void FlattenResult(QueryResultPart result, Action> writeRow) + { + if (result.Error != null) + return; + + if (result.Rows != null) + { + writeRow(result.Columns!); + foreach (var row in result.Rows) + { + writeRow(row); + } + } + else if (result.Slices != null) + { + writeRow(new object[] {"time"}.Concat(result.Columns!)); + + var empty = result.Columns!.Select(_ => "").ToArray(); + foreach (var slice in result.Slices) + { + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); + } + if (!any) + { + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); + } + } + } + else if (result.Series != null) + { + writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); + foreach (var series in result.Series) + { + foreach (var slice in series.Slices) + { + var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); + } + if (!any) + { + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); + } + } + } + } + else + { + throw new NotImplementedException("Query result set does not conform to any expected pattern."); + } + } + + static IEnumerable MergeColumns(IReadOnlyList columns, TimeseriesPart? firstSeries) + { + if (firstSeries == null) + yield break; + + var i = 0; + for (; i < firstSeries.Key.Length; ++i) + { + yield return columns[i]; + } + + yield return "time"; + + for (; i < columns.Count; ++i) + { + yield return columns[i]; + } + } + + static CallToolResult SimpleTextResult(string resultText, bool isError = false) { return new CallToolResult { - IsError = true, + IsError = isError, Content = [ new TextContentBlock { - Text = "The query tool is not implemented." + Text = resultText } ] }; 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 0b425248..79bea87a 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -6,7 +6,7 @@ metadata: author: Datalust and Contributors --- -Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +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. > This skill does not currently cover interactions with metrics (the `series` storage object). @@ -268,15 +268,17 @@ ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains metric samples. -## Search Expression Examples +## Expression Examples -| Example | Effect | -|---|-----------------------------------------------------| -| `@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). | +| Example | Effect | +|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `@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%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | ## Gotchas @@ -301,4 +303,7 @@ metric samples. - 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. - \ No newline at end of file + - 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`. + - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. diff --git a/src/SeqCli/Syntax/DurationMoniker.cs b/src/SeqCli/Syntax/DurationMoniker.cs index a579ff8d..0bc3d028 100644 --- a/src/SeqCli/Syntax/DurationMoniker.cs +++ b/src/SeqCli/Syntax/DurationMoniker.cs @@ -31,7 +31,7 @@ public static string FromTimeSpan(TimeSpan timeSpan) Component(timeSpan.TotalMinutes, "m"), Component(timeSpan.TotalSeconds, "s"), Component(timeSpan.TotalMilliseconds, "ms") - }.First(c => c != ""); + }.FirstOrDefault(c => c != "", $"{timeSpan.TotalMilliseconds}ms"); } static string Component(double value, string moniker) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs new file mode 100644 index 00000000..b5106f3e --- /dev/null +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -0,0 +1,16 @@ +using SeqCli.Mcp; +using Xunit; + +namespace SeqCli.Tests.Mcp; + +public class McpSessionTests +{ + [Fact] + public void ResultIdsRoundTrip() + { + const int id = 1245; + var formatted = McpSession.FormatResultId(id); + Assert.True(McpSession.TryParseResultId(formatted, out var rt)); + Assert.Equal(id, rt); + } +} \ No newline at end of file From f22d041742b6c972cc1301fa66b5da87f39a833a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 17:15:10 +1000 Subject: [PATCH 07/27] More fixes --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 4 ++-- src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs | 1 + src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index eb720146..8d9b073c 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -48,11 +48,11 @@ protected override async Task Run() if (_debug) { Log.Logger = new LoggerConfiguration() - .Enrich.WithProperty("Application", "seqcli") + .Enrich.WithProperty("Application", "seqcli mcp run") .WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector())) .CreateLogger(); - Log.Information("seqcli MCP server starting up"); + Log.Information("Seq MCP server starting up"); } try diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index f68d39c8..78cc730a 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -169,6 +169,7 @@ or byte or ushort or uint or ulong or UInt128 or if (value is JObject jo) { WriteObject(output, false, jo.Properties().Select(p => (p.Name, (object?)p.Value))); + return; } if (value is JValue jt) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 8f2f16fd..c0e3acf9 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -33,7 +33,7 @@ class SearchAndQueryToolType(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( - $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}\n{{#end}}" + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...\n{{#end}}" ); static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings @@ -263,7 +263,7 @@ public async Task QueryAsync( { var request = new HttpRequestMessage { - RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query)), + RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") }; var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); From 9cb90ee429f1f04035fab4bdb56040045bb950fe Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 17:26:36 +1000 Subject: [PATCH 08/27] More WIP --- .claude/skills/seq-search-and-query/SKILL.md | 4 ++++ .../Mcp/Tools/Search/SearchAndQueryToolType.cs | 12 ++++++------ .../Skills/Resources/seq-search-and-query/SKILL.md | 4 ++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index 79bea87a..de28d032 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -307,3 +307,7 @@ metric samples. - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. + - 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)`. diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index c0e3acf9..29f6b353 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -59,9 +59,9 @@ public async Task SearchEventsAsync( { if (!string.IsNullOrWhiteSpace(predicate)) { - if (!predicate.Contains("@Timestamp") && - !predicate.Contains("@Id") && - !predicate.Contains("@TraceId")) + if (!predicate.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + !predicate.Contains("@Id", StringComparison.OrdinalIgnoreCase) && + !predicate.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) { return SimpleTextResult("The predicate doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", isError: true); @@ -250,9 +250,9 @@ public async Task QueryAsync( string query, CancellationToken cancellationToken) { - if (!query.Contains("@Timestamp") && - !query.Contains("@Id") && - !query.Contains("@TraceId")) + if (!query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + !query.Contains("@Id", StringComparison.OrdinalIgnoreCase) && + !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) { return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); 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 79bea87a..de28d032 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -307,3 +307,7 @@ metric samples. - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. + - 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)`. From 1b47da13abe52e62e1ceb54a6d4b349cc7e74b2a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 18:01:59 +1000 Subject: [PATCH 09/27] Minor tweaks --- .claude/skills/seq-search-and-query/SKILL.md | 2 +- src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs | 6 ++++-- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index de28d032..4d4004c6 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -306,8 +306,8 @@ metric samples. - 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`. - - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. - 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)`. + - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 29f6b353..f2cd4dd2 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -250,9 +250,11 @@ public async Task QueryAsync( string query, CancellationToken cancellationToken) { - if (!query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + if (query.Contains("from", StringComparison.OrdinalIgnoreCase) && + (!query.Contains("where", StringComparison.OrdinalIgnoreCase) || + !query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && !query.Contains("@Id", StringComparison.OrdinalIgnoreCase) && - !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) + !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase))) { return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); 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 de28d032..4d4004c6 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -306,8 +306,8 @@ metric samples. - 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`. - - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. - 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)`. + - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. From aaaeb41fa5b016027751a098a58f4422ebc512e2 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 13:53:10 +1000 Subject: [PATCH 10/27] Remove .claude and add to .gitignore --- .claude/skills/seq-search-and-query/SKILL.md | 313 ------------------- .gitignore | 4 + 2 files changed, 4 insertions(+), 313 deletions(-) delete mode 100644 .claude/skills/seq-search-and-query/SKILL.md diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md deleted file mode 100644 index 4d4004c6..00000000 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ /dev/null @@ -1,313 +0,0 @@ ---- -name: seq-search-and-query -description: Search and query logs and spans in Seq. Use when interacting with Seq. -license: Apache-2.0 -metadata: - author: Datalust and Contributors ---- - -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. - -> This skill does not currently cover interactions with metrics (the `series` storage object). - -## 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. - -| 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. | -| `@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. | -| `@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`. | -| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | -| `@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. | -| `@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. | - -## Type System - -Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. - -| Type name | Description | Example literals | -|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| -| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | -| **boolean** | The atoms `true` and `false`. | `true`, `false` | -| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | -| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | -| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | - -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). - -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`. - -## Scalar Functions and Operators - -These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. - -| Function signature | Description | -|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | -| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | -| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | -| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | -| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `ToNumber(str: string): number?` | Parse string `str` as a number. | -| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | -| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | -| `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 `-` (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 `^` | 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 numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| 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 numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| 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 `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. | - -## Aggregate Functions - -`select` queries (see grammar below) have access to the following aggregate functions. - -| Aggregate function signature | Description | Example | -|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| -| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | -| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | -| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | -| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | -| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | -| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | -| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | -| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | -| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | -| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | -| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | -| `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | -| `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 - -### Base - -```ebnf -identifier = ( letter | '_' ) , { letter | digit | '_' } ; -built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; -variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; -letter = ? any Unicode letter ? ; -digit = ? any Unicode digit ? ; -string_literal = "'" , { string_char } , "'" ; -string_char = "''" | ? any character except single quote ? ; -number = natural , [ '.' , natural ] ; -hex_number = '0x' , hex_digit , { hex_digit } ; -natural = digit , { digit } ; -hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' - | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; -duration = { natural , time_unit }- ; -time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; -regular_expression = '/' , { regex_char } , '/' ; -regex_char = '\/' | ? any character except '/' ? ; -``` - -### Expression - -``` -Expr = Disjunction ; -Disjunction = Conjunction , { 'or' , Conjunction } ; -Conjunction = Comparison , { 'and' , Comparison } ; -Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; -comparison_op = 'not' , 'like' - | 'like' - | 'not' , 'in' - | 'in' - | '<=' | '<>' | '<' - | '>=' | '>' - | '=' ; -Comparand = Term , { ( '+' | '-' ) , Term } ; -Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; -InnerTerm = Operand , { '^' , Operand } ; - -Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; -unary_op = '-' | 'not' ; -null_test = 'null' | 'not' , 'null' ; -Path = Factor , { path_step } ; -path_step = '.' , identifier - | '[' , ( wildcard | Expr ) , ']' ; -wildcard = '?' | '*' ; -Factor = '(' , Expr , ')' - | Item ; -Item = Property - | Literal - | Function - | ArrayLiteral - | ObjectLiteral - | Conditional - | Block - | Lambda - | Variable ; -Property = built_in_identifier - | identifier ; (* when not followed by '(' *) -Literal = string_literal - | number - | hex_number - | duration - | regular_expression - | 'true' - | 'false' - | 'null' ; -Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; -function_name = identifier - | 'and' | 'not' | 'or' ; -arg_list = '*' (* only valid for count(*) *) - | [ Expr , { ',' , Expr } ] ; -ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; -ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; -ObjectMember = ( identifier | string_literal ) , ':' , Expr ; -Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; -Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; -Binding = identifier , ':' , Expr ; -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 - -```ebnf -Query = [ ExplainClause ] - SelectClause - [ IntoClause ] - [ FromClause ] - [ WhereClause ] - [ GroupByClause ] - [ HavingClause ] - [ OrderByClause ] - [ LimitClause ] - [ ForClause ] ; -ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; -SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; -SelectColumn = '*' - | Expr , [ 'as' , identifier ] ; -IntoClause = 'into' , variable ; -FromClause = 'from' , source , { LateralJoin } ; -source = 'stream' | 'series' ; -LateralJoin = 'lateral' , Expr , 'as' , identifier ; -WhereClause = 'where' , Expr ; -GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; -Grouping = TimeGrouping - | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; -TimeGrouping = 'time' , '(' , duration , ')' ; -HavingClause = 'having' , Expr ; -OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; -Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; -LimitClause = 'limit' , natural ; -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. - -## Expression Examples - -| Example | Effect | -|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@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%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | -| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | -| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | - -## Gotchas - - - 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 don'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 search result - 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 (`ulong` with 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)`. - - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. diff --git a/.gitignore b/.gitignore index fcfa8504..4e050436 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,7 @@ __pycache__/ global.json .DS_Store/ + +.claude/ +.qwen/ +.agents/ From 2229605a21d7494c3df524e6dfb87037e2d8768b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 14:29:42 +1000 Subject: [PATCH 11/27] Minimal tests for identifier generation --- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 122 +++++++++--------- src/SeqCli/Mcp/McpSession.cs | 47 ++----- src/SeqCli/Mcp/Schema/EventEntitySchema.cs | 47 +++++++ .../Tools/Search/SearchAndQueryToolType.cs | 2 +- .../Mcp/SeqSyntaxFormatterTests.cs | 20 +++ 5 files changed, 142 insertions(+), 96 deletions(-) create mode 100644 src/SeqCli/Mcp/Schema/EventEntitySchema.cs create mode 100644 test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 78cc730a..fc288617 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -11,13 +11,15 @@ namespace SeqCli.Mcp.Formatting; -// 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. +/// +/// 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 readonly object UndefinedValue = new(); - [GeneratedRegex("[_a-zA-Z][_a-zA-Z0-9]*")] + [GeneratedRegex("^[_a-zA-Z][_a-zA-Z0-9]*$")] private static partial Regex IdentifierRegex(); static readonly HashSet Keywords = new(StringComparer.OrdinalIgnoreCase) @@ -26,8 +28,22 @@ static partial class SeqSyntaxFormatter "analyze", "as", "asc", "by", "desc", "explain", "for", "from", "group", "having", "into", "lateral", "limit", "lower", "order", "select", "where" }; + + public static string MakeIdentifier(string prefixPath, string propertyName, bool prefixIsOptional) + { + if (IdentifierRegex().IsMatch(propertyName)) + { + if (prefixIsOptional && !Keywords.Contains(propertyName)) + return propertyName; + return $"{prefixPath}.{propertyName}"; + } + + var sw = new StringWriter(); + WriteValue(sw, propertyName); + return $"{prefixPath}[{sw}]"; + } - public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) + public static void WriteEvent(TextWriter output, EventEntity evt) { WriteObject( output, @@ -52,53 +68,6 @@ public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) ); } - static uint ParseEventType(string dollarPrefixedHex) - { - return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); - } - - static string ReconstructTemplate(IEnumerable tokens) - { - return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); - } - - static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) - { - output.Write('{'); - var first = true; - foreach (var (name, value) in members) - { - if (value == UndefinedValue) - continue; - - if (first) - first = false; - else - output.Write(", "); - - if (topLevel) - { - output.Write(name); - } - else - { - WriteMemberName(output, name); - } - - output.Write(": "); - - if (value is Action valueWriter) - { - valueWriter(output); - } - else - { - WriteValue(output, value); - } - } - output.Write('}'); - } - public static void WriteValue(TextWriter output, object? value) { if (value == UndefinedValue) @@ -198,17 +167,50 @@ static void WritePropertiesObject(TextWriter output, List mem WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); } - public static string MakeIdentifier(string prefixPath, bool optionalPrefix, string propertyName) + static uint ParseEventType(string dollarPrefixedHex) { - if (IdentifierRegex().IsMatch(propertyName)) + return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); + } + + static string ReconstructTemplate(IEnumerable tokens) + { + return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); + } + + static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) + { + output.Write('{'); + var first = true; + foreach (var (name, value) in members) { - if (optionalPrefix && !Keywords.Contains(propertyName)) - return propertyName; - return $"{prefixPath}.{propertyName}"; - } + if (value == UndefinedValue) + continue; - var sw = new StringWriter(); - WriteValue(sw, propertyName); - return $"{prefixPath}[{sw}]"; + if (first) + first = false; + else + output.Write(", "); + + if (topLevel) + { + output.Write(name); + } + else + { + WriteMemberName(output, name); + } + + output.Write(": "); + + if (value is Action valueWriter) + { + valueWriter(output); + } + else + { + WriteValue(output, value); + } + } + output.Write('}'); } } diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 099c0d36..41681e2f 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -5,8 +5,7 @@ using Seq.Api.Model.Events; using System; using System.Linq; -using Newtonsoft.Json.Linq; -using SeqCli.Mcp.Formatting; +using SeqCli.Mcp.Schema; namespace SeqCli.Mcp; @@ -16,7 +15,7 @@ class McpSession int _nextId = 1; readonly Dictionary _resultIdToEventId = new(); readonly Dictionary _eventIdToResult = new(); - + public string ImportSearchResult(EventEntity evt) { lock (_sync) @@ -38,7 +37,8 @@ internal static string FormatResultId(int resultId) internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) { - if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var parsed)) { resultId = null; return false; @@ -48,7 +48,8 @@ internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out return true; } - public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, [NotNullWhen(false)] out string? error) + public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, + [NotNullWhen(false)] out string? error) { if (!TryParseResultId(resultId, out var parsed)) { @@ -57,7 +58,7 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt "The result id is not correctly formatted; result ids are strings beginning with `R`, followed by a short character string."; return false; } - + lock (_sync) { if (!_resultIdToEventId.TryGetValue(parsed.Value, out var eventId)) @@ -89,37 +90,13 @@ public IEnumerable EnumerateUserPropertyNames(CancellationToken cancella foreach (var evt in all) { cancellationToken.ThrowIfCancellationRequested(); - - foreach (var property in evt.Properties ?? []) - { - foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) - yield return unique; - } - foreach (var property in evt.Scope ?? []) - { - foreach (var unique in EnumerateUnique(seen, "@Scope", false, property.Name, property.Value, 1)) - yield return unique; - } - foreach (var property in evt.Resource ?? []) - { - foreach (var unique in EnumerateUnique(seen, "@Resource", false, property.Name, property.Value, 1)) - yield return unique; - } - } - } - static IEnumerable EnumerateUnique(HashSet seen, string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) - { - var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, optionalPrefix, propertyName); - if (seen.Add(name)) - yield return name; - - if (depth < 5 && propertyValue is JObject jo) - { - foreach (var child in jo.Properties()) + foreach (var accessor in EventEntitySchema.EnumeratePropertyAccessorPaths(evt)) { - foreach (var childName in EnumerateUnique(seen, name, false, child.Name, child.Value, depth + 1)) - yield return childName; + if (seen.Add(accessor)) + { + yield return accessor; + } } } } diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs new file mode 100644 index 00000000..80312e00 --- /dev/null +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using SeqCli.Mcp.Formatting; + +namespace SeqCli.Mcp.Schema; + +static class EventEntitySchema +{ + const int MaxAccessorPathDepth = 5; + + public static IEnumerable EnumeratePropertyAccessorPaths(EventEntity evt) + { + foreach (var property in evt.Properties ?? []) + { + foreach (var accessor in EnumerateAccessorPaths("@Properties", true, property.Name, property.Value, 1)) + yield return accessor; + } + + foreach (var property in evt.Scope ?? []) + { + foreach (var accessor in EnumerateAccessorPaths("@Scope", false, property.Name, property.Value, 1)) + yield return accessor; + } + + foreach (var property in evt.Resource ?? []) + { + foreach (var accessor in EnumerateAccessorPaths("@Resource", false, property.Name, property.Value, 1)) + yield return accessor; + } + } + + static IEnumerable EnumerateAccessorPaths(string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) + { + var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, propertyName, optionalPrefix); + yield return name; + + if (depth < MaxAccessorPathDepth && propertyValue is JObject jo) + { + foreach (var child in jo.Properties()) + { + foreach (var childName in EnumerateAccessorPaths(name, false, child.Name, child.Value, depth + 1)) + yield return childName; + } + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index f2cd4dd2..acfeff03 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -227,7 +227,7 @@ public Task ReadSearchResultJsonAsync( } var resultText = new StringWriter(); - SeqSyntaxFormatter.FormatAsObjectLiteral(result, resultText); + SeqSyntaxFormatter.WriteEvent(resultText, result); return Task.FromResult(SimpleTextResult(resultText.ToString())); } diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs new file mode 100644 index 00000000..fccde0e3 --- /dev/null +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -0,0 +1,20 @@ +using SeqCli.Mcp.Formatting; +using Xunit; + +namespace SeqCli.Tests.Mcp; + +public class SeqSyntaxFormatterTests +{ + [Theory] + [InlineData("@Properties", "a", true, "a")] + [InlineData("@Properties", "a b", true, "@Properties['a b']")] + [InlineData("@Properties", "and", true, "@Properties.and")] + [InlineData("@Resource", "a", false, "@Resource.a")] + [InlineData("@Resource", "a b", false, "@Resource['a b']")] + [InlineData("@Resource", "and", false, "@Resource.and")] + public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, bool prefixIsOptional, string expected) + { + var actual = SeqSyntaxFormatter.MakeIdentifier(prefix, name, prefixIsOptional); + Assert.Equal(expected, actual); + } +} \ No newline at end of file From 76a6cece4aa7c7945730673b3b35ace94fa65d10 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 14:42:16 +1000 Subject: [PATCH 12/27] OTel property name accessor gotcha --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 3 +++ 1 file changed, 3 insertions(+) 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 4d4004c6..d9353c48 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -279,6 +279,7 @@ metric samples. | `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | | `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | | `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | +| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | ## Gotchas @@ -311,3 +312,5 @@ metric samples. - 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)`. - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. + - 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. From c19d8398a4eed3fde98ec1546d931eeda29d7327 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 14:53:31 +1000 Subject: [PATCH 13/27] More gotchas; these are a useful catalog of papercuts we might actually want to tackle on the Seq side --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 d9353c48..0d85fffd 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -255,11 +255,13 @@ LateralJoin = 'lateral' , Expr , 'as' , identifier ; WhereClause = 'where' , Expr ; GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; Grouping = TimeGrouping - | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; + | Expr , [ 'ci' ] , [ 'as' , identifier ] ; TimeGrouping = 'time' , '(' , duration , ')' ; HavingClause = 'having' , Expr ; OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; -Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; +Ordering = TimeOrdering + | Expr , [ 'ci' ] , [ 'asc' | 'desc' ] ; +TimeOrdering = 'time' ; LimitClause = 'limit' , natural ; ForClause = 'for' , ForOption , { ',' , ForOption } ; ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; @@ -314,3 +316,5 @@ metric samples. - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. - 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. + - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't + re-specified. From 9f131b901adc8717201c09a2eb08f94107159bde Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 17:31:46 +1000 Subject: [PATCH 14/27] Get the (implemented) integration tests running. Assisted-by: Claude 4.8 Opus --- .../Mcp/McpSessionBasicsTestCase.cs | 29 +++++++++++++++++++ test/SeqCli.EndToEnd/Program.cs | 6 ++++ .../Skills/SkillsInstallTestCase.cs | 17 +++-------- .../SeqCli.EndToEnd/Support/CaptiveProcess.cs | 7 +++-- .../Support/CliCommandRunner.cs | 8 ++--- .../Support/TestConfiguration.cs | 6 ++-- 6 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs new file mode 100644 index 00000000..580a7899 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; + +namespace SeqCli.EndToEnd.Mcp; + +public class McpSessionBasicsTestCase: ICliTestCase +{ + public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + // Log a handful of simple informational events through the logger. It's worth going to some effort to add + // some nested structured properties. + + // Configure an `McpClient` connected to `seqcli mcp run` pointing to the shared Seq connection. + + // Call the search tool and verify that the events are (conditionally) found + + // Call the result inspection tool and pull back each event using the ids returned in the search results, + // ensuring they're what we expect. + + // Call the schema tool and check that all of the property paths from all of the events are present. + + // Call the query tool and compute an aggregate over the events, making sure we get the expected result set. + + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Program.cs b/test/SeqCli.EndToEnd/Program.cs index 35a392c9..71a02c7e 100644 --- a/test/SeqCli.EndToEnd/Program.cs +++ b/test/SeqCli.EndToEnd/Program.cs @@ -5,6 +5,12 @@ using Serilog; using Serilog.Debugging; +// Test cases reference data files using paths relative to the working directory (e.g. `Data/log-*.txt`), +// which are copied alongside the test binary. Anchor the working directory to the build output so the +// suite behaves identically however it's launched - `dotnet run --project ...` from the repo root, or +// `cd`-ing into the test directory first as CI does. +Environment.CurrentDirectory = AppContext.BaseDirectory; + Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateLogger(); diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 00546bbf..831ad5b9 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using System.Threading.Tasks; using Seq.Api; @@ -13,18 +12,10 @@ public class SkillsInstallTestCase : ICliTestCase public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) { using var tmp = new TestDataFolder(); - var previous = Environment.CurrentDirectory; - Environment.CurrentDirectory = tmp.Path; - try - { - var exit = runner.Exec("skills install -a test-agent"); - Assert.Equal(0, exit); - Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); - } - finally - { - Environment.CurrentDirectory = previous; - } + + var exit = runner.Exec("skills install -a test-agent", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, exit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs index c9b6ed1d..726da5e6 100644 --- a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs +++ b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs @@ -25,7 +25,8 @@ public CaptiveProcess( bool captureOutput = true, bool supplyInput = false, string stopCommandFullExePath = null, - string stopCommandArgs = null) + string stopCommandArgs = null, + string workingDirectory = null) { ArgumentNullException.ThrowIfNull(fullExePath); _captureOutput = captureOutput; @@ -42,7 +43,9 @@ public CaptiveProcess( CreateNoWindow = true, ErrorDialog = false, FileName = fullExePath, - Arguments = args ?? "" + Arguments = args ?? "", + // An empty working directory means the child inherits the parent process's current directory. + WorkingDirectory = workingDirectory ?? "" }; if (environment != null) diff --git a/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs b/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs index 6061017e..d4e0dfeb 100644 --- a/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs +++ b/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs @@ -17,15 +17,15 @@ public class CliCommandRunner(TestConfiguration configuration, TestDataFolder te public ITestProcess? LastRunProcess { get; private set; } - public int Exec(string command, string? args = null, bool disconnected = false, Dictionary? environment = null, TimeSpan? timeout = null) + public int Exec(string command, string? args = null, bool disconnected = false, Dictionary? environment = null, TimeSpan? timeout = null, string? workingDirectory = null) { - using var process = Spawn(command, args, disconnected, environment); + using var process = Spawn(command, args, disconnected, environment, workingDirectory); return process.WaitForExit(timeout ?? DefaultExecTimeout); } - public CaptiveProcess Spawn(string command, string? args = null, bool disconnected = false, Dictionary? environment = null) + public CaptiveProcess Spawn(string command, string? args = null, bool disconnected = false, Dictionary? environment = null, string? workingDirectory = null) { - var process = configuration.SpawnCliProcess(command, args, environment, skipServerArg: disconnected); + var process = configuration.SpawnCliProcess(command, args, environment, skipServerArg: disconnected, workingDirectory: workingDirectory); LastRunProcess = process; return process; } diff --git a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs index e3b35aa8..fff6c7af 100644 --- a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs +++ b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs @@ -31,15 +31,15 @@ public TestConfiguration(Args args) public bool IsMultiuser => _args.Multiuser(); - public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false) + public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false, string workingDirectory = null) { if (command == null) throw new ArgumentNullException(nameof(command)); var commandWithArgs = $"{command} {additionalArgs}"; if (!skipServerArg) commandWithArgs += $" --server=\"{ServerListenUrl}\""; - - return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput); + + return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput, workingDirectory: workingDirectory); } public CaptiveProcess SpawnServerProcess(string storagePath) From 8ca84d5c40091a7a72774f3d90009e7c4c5acff1 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 18:40:15 +1000 Subject: [PATCH 15/27] Added a simple MCP end-to-end test case. Assisted-by: Claude 4.8 Opus --- .../Mcp/McpSessionBasicsTestCase.cs | 86 +++++++++++++++---- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index 580a7899..b57ba006 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -1,29 +1,79 @@ 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; -public class McpSessionBasicsTestCase: ICliTestCase +// ReSharper disable once UnusedType.Global +public partial class McpSessionBasicsTestCase : ICliTestCase { - public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) { - // Log a handful of simple informational events through the logger. It's worth going to some effort to add - // some nested structured properties. - - // Configure an `McpClient` connected to `seqcli mcp run` pointing to the shared Seq connection. - - // Call the search tool and verify that the events are (conditionally) found - - // Call the result inspection tool and pull back each event using the ids returned in the search results, - // ensuring they're what we expect. - - // Call the schema tool and check that all of the property paths from all of the events are present. - - // Call the query tool and compute an aggregate over the events, making sure we get the expected result set. - - throw new NotImplementedException(); + var runId = "mcp-" + Guid.NewGuid().ToString("n"); + + var orders = new[] + { + (Number: 1, Amount: 2, Customer: new { Name = "Alice", Tier = "gold", Address = new { City = "Sydney" } }), + (Number: 2, Amount: 1, Customer: new { Name = "Bob", Tier = "silver", Address = new { City = "Hobart" } }), + (Number: 3, Amount: 3, Customer: new { Name = "Carol", Tier = "gold", Address = new { City = "Perth" } }), + }; + + foreach (var order in orders) + { + logger.Information("Order {OrderNumber} in run {RunId} placed by {@Customer} for {Amount} unit(s)", + 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(); + Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length); + + var detailResult = AssertTextResult(await client.CallToolAsync( + "seq_read_search_result", + new Dictionary { ["result_id"] = resultIds[0] })); + Assert.Contains(runId, detailResult); + Assert.Contains("Name: 'Carol'", detailResult); + + 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" }) + 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( + "seq_query", + new Dictionary { ["query"] = query })); + Assert.Contains("Total", queryResult); + Assert.Contains("6", queryResult); + } + + 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; } -} \ No newline at end of file + + [GeneratedRegex("R[0-9a-zA-Z]+")] + private static partial Regex ResultIdRegex(); +} From ae29b25531da98fbf9db311cde879132cd492227 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 21:12:59 +1000 Subject: [PATCH 16/27] Add tests for SeqSyntaxFormatter. Assisted-by: Claude 4.8 Opus --- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 2 +- src/SeqCli/Mcp/McpSession.cs | 2 +- .../Mcp/SeqSyntaxFormatterTests.cs | 162 +++++++++++++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index fc288617..741ebed6 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -121,7 +121,7 @@ or byte or ushort or uint or ulong or UInt128 or if (value is JArray ja) { - var first = false; + var first = true; output.Write('['); foreach (var element in ja) { diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 41681e2f..26c75f7e 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -37,7 +37,7 @@ internal static string FormatResultId(int resultId) internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) { - if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, + if (!formatted.StartsWith('R') || !int.TryParse(formatted[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) { resultId = null; diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs index fccde0e3..c883b49c 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -1,3 +1,11 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; using SeqCli.Mcp.Formatting; using Xunit; @@ -17,4 +25,156 @@ public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, boo var actual = SeqSyntaxFormatter.MakeIdentifier(prefix, name, prefixIsOptional); Assert.Equal(expected, actual); } -} \ No newline at end of file + + [Theory] + [MemberData(nameof(BuiltInPropertyCases))] + public void BuiltInPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable BuiltInPropertyCases() => + [ + [MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], + [MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], + [MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], + [MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], + [ + MakeEvent(e => e.MessageTemplateTokens = + [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]), + "@MessageTemplate: 'User {UserId}'" + ], + [MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], + [MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], + [MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], + [MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], + [MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"], + [MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"], + [MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"], + [MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"], + [MakeEvent(e => e.Properties = MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], + [MakeEvent(e => e.Scope = MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], + [MakeEvent(e => e.Resource = MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], + [MakeEvent(e => e.Definitions = MakeProperties(("d", 1))), "@Definitions: {d: 1}"] + ]; + + [Theory] + [InlineData("@Exception")] + [InlineData("@Elapsed")] + [InlineData("@TraceId")] + [InlineData("@SpanId")] + [InlineData("@SpanKind")] + [InlineData("@Start")] + [InlineData("@ParentId")] + [InlineData("@Properties")] + [InlineData("@Scope")] + [InlineData("@Resource")] + [InlineData("@Definitions")] + public void OptionalPropertiesAreOmittedWhenAbsent(string token) + { + Assert.DoesNotContain(token, Render(MakeEvent())); + } + + [Fact] + public void EmptyPropertyCollectionIsOmitted() + { + Assert.DoesNotContain("@Properties", Render(MakeEvent(e => e.Properties = []))); + } + + [Fact] + public void EventFormatIsAnObjectLiteral() + { + Assert.Equal( + "{@Id: 'event-1', @Timestamp: DateTime('2024-01-01T00:00:00.0000000Z'), " + + "@Level: 'Information', @Message: 'Hello', @MessageTemplate: 'Hello', @EventType: 0}", + Render(MakeEvent())); + } + + [Theory] + [MemberData(nameof(BasicPropertyFormattingCases))] + public void BasicPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable BasicPropertyFormattingCases() => + [ + [MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], + [MakeEvent(e => e.Level = null), "@Level: 'Information'"], + [MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" + ], + [MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], + [MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], + [MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" + ], + [MakeEvent(e => e.Properties = MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], + [MakeEvent(e => e.Properties = MakeProperties(("n", 42))), "@Properties: {n: 42}"], + [MakeEvent(e => e.Properties = MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], + [MakeEvent(e => e.Properties = MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], + [MakeEvent(e => e.Properties = MakeProperties(("b", true))), "@Properties: {b: true}"], + [MakeEvent(e => e.Properties = MakeProperties(("z", null))), "@Properties: {z: null}"] + ]; + + [Theory] + [MemberData(nameof(NestedPropertyCases))] + public void NestedPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable NestedPropertyCases() => + [ + [ + MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web" }))), + "@Resource: {service: {name: 'web'}}" + ], + [ + MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), + "@Resource: {service: {name: 'web', version: '1.0'}}" + ], + [ + MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), + "@Resource: {service: {namespace: {name: 'web'}}}" + ], + [ + MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), + "@Properties: {http: {request: {method: 'GET'}}}" + ], + [ + MakeEvent(e => e.Scope = MakeProperties(("db", new JObject { ["system"] = "postgres" }))), + "@Scope: {db: {system: 'postgres'}}" + ], + [ + MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["content-type"] = "json" }))), + "@Properties: {http: {'content-type': 'json'}}" + ], + [ + MakeEvent(e => e.Properties = MakeProperties(("tags", new JArray("a", "b")))), + "@Properties: {tags: ['a', 'b']}" + ] + ]; + + static EventEntity MakeEvent(Action? configure = null) + { + var evt = new EventEntity + { + Id = "event-1", + Timestamp = "2024-01-01T00:00:00.0000000Z", + RenderedMessage = "Hello", + MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello" }], + EventType = "$00000000", + }; + configure?.Invoke(evt); + return evt; + } + + static List MakeProperties(params (string Name, object? Value)[] items) => + items.Select(i => new EventPropertyPart(i.Name, i.Value)).ToList(); + + static string Render(EventEntity evt) + { + var output = new StringWriter(); + SeqSyntaxFormatter.WriteEvent(output, evt); + return output.ToString(); + } +} From 093bccd67c9e5ee9f56e1a87d10248707affba35 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 08:53:32 +1000 Subject: [PATCH 17/27] A first cut `mcp install` command. Assisted-by: Claude 4.8 Opus --- src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs | 52 +++++++++ src/SeqCli/Mcp/McpServerInstaller.cs | 107 ++++++++++++++++++ .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 56 +++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs create mode 100644 src/SeqCli/Mcp/McpServerInstaller.cs create mode 100644 test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs diff --git a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs new file mode 100644 index 00000000..d847e277 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs @@ -0,0 +1,52 @@ +// 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.Threading.Tasks; +using SeqCli.Mcp; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Mcp; + +[Command("mcp", "install", "Install or update the Seq MCP server for an agent", + Example = "seqcli mcp install --global --agent claude")] +class InstallCommand : Command +{ + bool _global; + string? _agent; + string? _profile; + + public InstallCommand() + { + Options.Add( + "g|global", + "Install for the current user globally; the default is to install into the current project directory", + _ => _global = true); + + Options.Add( + "a=|agent=", + "The agent name to install the MCP server for; the default is the generic name `agents`", + t => _agent = ArgumentString.Normalize(t)); + + Options.Add( + "profile=", + "A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used", + v => _profile = ArgumentString.Normalize(v)); + } + + protected override Task Run() + { + McpServerInstaller.Install(_agent?.ToLowerInvariant() ?? "agents", _global, _profile); + return Task.FromResult(0); + } +} diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs new file mode 100644 index 00000000..b1314a85 --- /dev/null +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -0,0 +1,107 @@ +// 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.IO; +using Newtonsoft.Json.Linq; +using Serilog; + +namespace SeqCli.Mcp; + +static class McpServerInstaller +{ + const string ServerName = "seq"; + + // Agents whose MCP config location or shape diverges from the common + // `.{agent}/mcp.json` + `mcpServers` convention. Anything not listed here - + // including the default `agents` name and any unknown agent - uses the + // convention (see `Convention`), so adding support for a conformant agent + // requires no change at all, and a divergent one is a single entry here. + static readonly IReadOnlyDictionary KnownAgents = + new Dictionary + { + // Claude Code reads project servers from a root `.mcp.json`, and + // user-global servers from `~/.claude.json`. + ["claude"] = new( + global => global + ? Path.Combine(UserProfile, ".claude.json") + : Path.Combine(Environment.CurrentDirectory, ".mcp.json"), + "mcpServers"), + + // Windsurf keeps a single user-global config under `~/.codeium`. + ["windsurf"] = new( + global => global + ? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json") + : Path.Combine(Environment.CurrentDirectory, ".windsurf", "mcp.json"), + "mcpServers"), + + // VS Code nests servers under a `servers` key. Project config lives in + // `.vscode/mcp.json`; the user-global equivalent lives inside `settings.json`, + // which is a different merge target and isn't supported here yet. + ["vscode"] = new( + global => global + ? throw new NotSupportedException( + "VS Code stores user-level MCP servers in settings.json; install into a project with `seqcli mcp install --agent vscode` instead.") + : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), + "servers"), + }; + + public static void Install(string agent, bool global, string? profileName = null) + { + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); + var path = target.ResolvePath(global); + + // Merge into any existing config so other servers and unrelated settings survive. + var root = File.Exists(path) ? JObject.Parse(File.ReadAllText(path)) : new JObject(); + + if (root[target.ServerMapKey] is not JObject serverMap) + { + serverMap = new JObject(); + root[target.ServerMapKey] = serverMap; + } + + // A connection profile is the only connection setting we propagate; the server URL and + // API key are resolved from config at runtime so they're not baked into the agent's file. + var args = new JArray("mcp", "run"); + if (profileName != null) + { + args.Add("--profile"); + args.Add(profileName); + } + + serverMap[ServerName] = new JObject + { + ["command"] = "seqcli", + ["args"] = args, + }; + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, root.ToString(Newtonsoft.Json.Formatting.Indented)); + + Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); + } + + static AgentTarget Convention(string agent) => + new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + $".{agent}", + "mcp.json"), + "mcpServers"); + + static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + sealed record AgentTarget(Func ResolvePath, string ServerMapKey); +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs new file mode 100644 index 00000000..6092edd0 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +// ReSharper disable once UnusedType.Global +public class McpInstallTestCase : ICliTestCase +{ + public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + using var tmp = new TestDataFolder(); + + // Convention fallback: an agent that isn't specially known writes `.{agent}/mcp.json` + // with the common `mcpServers` shape pointing at a bare `mcp run`. + var exit = runner.Exec("mcp install -a test-agent", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, exit); + + var config = File.ReadAllText(Path.Combine(tmp.Path, ".test-agent/mcp.json")); + Assert.Contains("\"mcpServers\"", config); + Assert.Contains("\"seq\"", config); + Assert.Contains("\"seqcli\"", config); + + // Known-agent override: Claude Code reads a root `.mcp.json`, not `.claude/mcp.json`. + var claudeExit = runner.Exec("mcp install -a claude", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, claudeExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".mcp.json"))); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".claude/mcp.json"))); + + // Merge preserves any server already present rather than overwriting the file. + var mergePath = Path.Combine(tmp.Path, ".merge-agent/mcp.json"); + Directory.CreateDirectory(Path.GetDirectoryName(mergePath)!); + File.WriteAllText(mergePath, "{\"mcpServers\":{\"other\":{\"command\":\"x\"}}}"); + + var mergeExit = runner.Exec("mcp install -a merge-agent", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, mergeExit); + + var merged = File.ReadAllText(mergePath); + Assert.Contains("\"other\"", merged); + Assert.Contains("\"seq\"", merged); + + // A `--profile` is propagated onto the generated server's `mcp run` args; other + // connection settings (server URL, API key) are deliberately left to runtime config. + var profileExit = runner.Exec("mcp install -a profile-agent --profile Production", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, profileExit); + + var profileConfig = File.ReadAllText(Path.Combine(tmp.Path, ".profile-agent/mcp.json")); + Assert.Contains("\"--profile\"", profileConfig); + Assert.Contains("\"Production\"", profileConfig); + + return Task.CompletedTask; + } +} From c2eb030b908273d32d097e4425e4233d20f9238e Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 11:59:07 +1000 Subject: [PATCH 18/27] Tidy up. Assisted-by: Claude 4.8 Opus --- src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs | 2 +- .../Cli/Commands/Skills/InstallCommand.cs | 12 +----------- src/SeqCli/Mcp/McpServerInstaller.cs | 4 +++- src/SeqCli/Skills/SkillInstaller.cs | 19 +++++++++++++++---- .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 9 +++++++++ 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs index d847e277..8b1bed91 100644 --- a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs @@ -46,7 +46,7 @@ public InstallCommand() protected override Task Run() { - McpServerInstaller.Install(_agent?.ToLowerInvariant() ?? "agents", _global, _profile); + McpServerInstaller.Install(_agent?.ToLowerInvariant(), _global, _profile); return Task.FromResult(0); } } diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs index 9be88fc1..3b67b63a 100644 --- a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -12,12 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.IO; using System.Threading.Tasks; using SeqCli.Skills; using SeqCli.Util; -using Serilog; namespace SeqCli.Cli.Commands.Skills; @@ -43,14 +40,7 @@ public InstallCommand() protected override Task Run() { - var skillsPath = Path.Combine( - _global ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Environment.CurrentDirectory, - $".{_agent?.ToLowerInvariant() ?? "agents"}", - "skills"); - - Log.Information("Installing skills to {SkillsPath}", skillsPath); - SkillInstaller.Install(skillsPath); - + SkillInstaller.Install(_agent?.ToLowerInvariant(), _global); return Task.FromResult(0); } } \ No newline at end of file diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index b1314a85..60a0f23c 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -58,8 +58,10 @@ static class McpServerInstaller "servers"), }; - public static void Install(string agent, bool global, string? profileName = null) + public static void Install(string? agent, bool global, string? profileName = null) { + agent ??= "agents"; + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); var path = target.ResolvePath(global); diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 123b7ea2..57b96f9c 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -20,21 +20,32 @@ namespace SeqCli.Skills; static class SkillInstaller { - public static void Install(string destinationPath) + public static void Install(string? agent, bool global) { + agent ??= "agents"; + + var destinationPath = Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + $".{agent}", + "skills"); + + Log.Information("Installing skills to {SkillsPath}", destinationPath); + var sourcePath = Path.Combine(AppContext.BaseDirectory, "Skills"); - + foreach (var skillSourceDirectory in Directory.EnumerateDirectories(sourcePath)) { var skillName = Path.GetFileName(skillSourceDirectory); var destination = Path.Combine(destinationPath, skillName); - + Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath); - + CopyFilesRecursive(skillSourceDirectory, destination); } } + static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + static void CopyFilesRecursive(string source, string destination) { Directory.CreateDirectory(destination); diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs index 6092edd0..7f9702eb 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -51,6 +51,15 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Contains("\"--profile\"", profileConfig); Assert.Contains("\"Production\"", profileConfig); + // VS Code has no supported user-global merge target. + var vscodeGlobalExit = runner.Exec("mcp install -a vscode --global", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, vscodeGlobalExit); + + var vscodeGlobalOutput = runner.LastRunProcess!.Output; + Assert.Contains("VS Code stores user-level MCP servers", vscodeGlobalOutput); + Assert.Contains("seqcli mcp install --agent vscode", vscodeGlobalOutput); + Assert.DoesNotContain("NotSupportedException", vscodeGlobalOutput); + return Task.CompletedTask; } } From 4d4f68fbb5545cf1fd2abaf6b126dbcf0c7ecd38 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 12:48:36 +1000 Subject: [PATCH 19/27] Increase test coverage for McpSession. Assisted-by: Claude 4.8 Opus --- test/SeqCli.Tests/Mcp/McpSessionTests.cs | 125 +++++++++++++++++- .../Mcp/SeqSyntaxFormatterTests.cs | 109 +++++++-------- test/SeqCli.Tests/Support/Some.cs | 23 +++- 3 files changed, 192 insertions(+), 65 deletions(-) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs index b5106f3e..ac644a36 100644 --- a/test/SeqCli.Tests/Mcp/McpSessionTests.cs +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -1,4 +1,10 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading; +using Newtonsoft.Json.Linq; using SeqCli.Mcp; +using SeqCli.Tests.Support; using Xunit; namespace SeqCli.Tests.Mcp; @@ -13,4 +19,121 @@ public void ResultIdsRoundTrip() Assert.True(McpSession.TryParseResultId(formatted, out var rt)); Assert.Equal(id, rt); } -} \ No newline at end of file + + [Fact] + public void ImportingTheSameEventReturnsTheSameId() + { + var session = new McpSession(); + + var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + + Assert.Equal(first, second); + } + + [Fact] + public void ImportingDistinctEventsReturnsDistinctIds() + { + var session = new McpSession(); + + var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-2")); + + Assert.NotEqual(first, second); + } + + [Fact] + public void ImportedEventsCanBeRetrievedById() + { + var session = new McpSession(); + var evt = Some.MakeEvent(); + + var resultId = session.ImportSearchResult(evt); + + Assert.True(session.TryGetSearchResult(resultId, out var result, out var error)); + Assert.Same(evt, result); + Assert.Null(error); + } + + [Fact] + public void MalformedResultIdsAreRejected() + { + var session = new McpSession(); + + Assert.False(session.TryGetSearchResult("not-a-result-id", out var result, out var error)); + Assert.Null(result); + Assert.NotNull(error); + } + + [Fact] + public void WellFormedButUnknownResultIdsReturnAnError() + { + var session = new McpSession(); + var unknown = McpSession.FormatResultId(999); + + Assert.False(session.TryGetSearchResult(unknown, out var result, out var error)); + Assert.Null(result); + Assert.NotNull(error); + } + + [Fact] + public void NoUserPropertyNamesAreEnumeratedWithoutResults() + { + var session = new McpSession(); + + Assert.Empty(session.EnumerateUserPropertyNames(CancellationToken.None)); + } + + [Fact] + public void UserPropertyNamesAreEnumeratedAcrossPropertiesScopeAndResource() + { + var session = new McpSession(); + session.ImportSearchResult(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Properties = Some.MakeProperties(("UserId", 42)); + e.Scope = Some.MakeProperties(("name", "my-scope")); + e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" })); + })); + + var names = session.EnumerateUserPropertyNames(CancellationToken.None).ToList(); + + Assert.Contains("UserId", names); + Assert.Contains("@Scope.name", names); + Assert.Contains("@Resource.service", names); + Assert.Contains("@Resource.service.name", names); + } + + [Fact] + public void UserPropertyNamesAreDeduplicatedAcrossResults() + { + var session = new McpSession(); + session.ImportSearchResult(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Properties = Some.MakeProperties(("UserId", 1)); + })); + session.ImportSearchResult(Some.MakeEvent(e => + { + e.Id = "event-2"; + e.Properties = Some.MakeProperties(("UserId", 2)); + })); + + var names = session.EnumerateUserPropertyNames(CancellationToken.None).ToList(); + + Assert.Equal(["UserId"], names); + } + + [Fact] + public void EnumeratingUserPropertyNamesObservesCancellation() + { + var session = new McpSession(); + session.ImportSearchResult(Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42)))); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws( + () => session.EnumerateUserPropertyNames(cts.Token).ToList()); + } +} diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs index c883b49c..59e12ee9 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -2,11 +2,10 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; -using Seq.Api.Model.Shared; using SeqCli.Mcp.Formatting; +using SeqCli.Tests.Support; using Xunit; namespace SeqCli.Tests.Mcp; @@ -35,27 +34,27 @@ public void BuiltInPropertiesAreFormatted(EventEntity evt, string expectedLitera public static IEnumerable BuiltInPropertyCases() => [ - [MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], - [MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], - [MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], - [MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], + [Some.MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], + [Some.MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], + [Some.MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], + [Some.MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], [ - MakeEvent(e => e.MessageTemplateTokens = + Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]), "@MessageTemplate: 'User {UserId}'" ], - [MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], - [MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], - [MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], - [MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], - [MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"], - [MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"], - [MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"], - [MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"], - [MakeEvent(e => e.Properties = MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], - [MakeEvent(e => e.Scope = MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], - [MakeEvent(e => e.Resource = MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], - [MakeEvent(e => e.Definitions = MakeProperties(("d", 1))), "@Definitions: {d: 1}"] + [Some.MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], + [Some.MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], + [Some.MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], + [Some.MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], + [Some.MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"], + [Some.MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"], + [Some.MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"], + [Some.MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], + [Some.MakeEvent(e => e.Scope = Some.MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], + [Some.MakeEvent(e => e.Resource = Some.MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], + [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"] ]; [Theory] @@ -72,22 +71,29 @@ public static IEnumerable BuiltInPropertyCases() => [InlineData("@Definitions")] public void OptionalPropertiesAreOmittedWhenAbsent(string token) { - Assert.DoesNotContain(token, Render(MakeEvent())); + Assert.DoesNotContain(token, Render(Some.MakeEvent())); } [Fact] public void EmptyPropertyCollectionIsOmitted() { - Assert.DoesNotContain("@Properties", Render(MakeEvent(e => e.Properties = []))); + Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = []))); } [Fact] public void EventFormatIsAnObjectLiteral() { Assert.Equal( - "{@Id: 'event-1', @Timestamp: DateTime('2024-01-01T00:00:00.0000000Z'), " + - "@Level: 'Information', @Message: 'Hello', @MessageTemplate: 'Hello', @EventType: 0}", - Render(MakeEvent())); + "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " + + "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}", + Render(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Timestamp = "2024-01-02T00:00:00.0000002Z"; + e.RenderedMessage = "Hello!"; + e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }]; + e.EventType = "$00000001"; + }))); } [Theory] @@ -99,20 +105,20 @@ public void BasicPropertiesAreFormatted(EventEntity evt, string expectedLiteral) public static IEnumerable BasicPropertyFormattingCases() => [ - [MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], - [MakeEvent(e => e.Level = null), "@Level: 'Information'"], - [MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" + [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], + [Some.MakeEvent(e => e.Level = null), "@Level: 'Information'"], + [Some.MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" ], - [MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], - [MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], - [MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" + [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], + [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], + [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" ], - [MakeEvent(e => e.Properties = MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], - [MakeEvent(e => e.Properties = MakeProperties(("n", 42))), "@Properties: {n: 42}"], - [MakeEvent(e => e.Properties = MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], - [MakeEvent(e => e.Properties = MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], - [MakeEvent(e => e.Properties = MakeProperties(("b", true))), "@Properties: {b: true}"], - [MakeEvent(e => e.Properties = MakeProperties(("z", null))), "@Properties: {z: null}"] + [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'}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("b", true))), "@Properties: {b: true}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"] ]; [Theory] @@ -125,51 +131,34 @@ public void NestedPropertiesAreFormatted(EventEntity evt, string expectedLiteral public static IEnumerable NestedPropertyCases() => [ [ - MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web" }))), + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }))), "@Resource: {service: {name: 'web'}}" ], [ - MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), "@Resource: {service: {name: 'web', version: '1.0'}}" ], [ - MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), "@Resource: {service: {namespace: {name: 'web'}}}" ], [ - MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), "@Properties: {http: {request: {method: 'GET'}}}" ], [ - MakeEvent(e => e.Scope = MakeProperties(("db", new JObject { ["system"] = "postgres" }))), + Some.MakeEvent(e => e.Scope = Some.MakeProperties(("db", new JObject { ["system"] = "postgres" }))), "@Scope: {db: {system: 'postgres'}}" ], [ - MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["content-type"] = "json" }))), + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["content-type"] = "json" }))), "@Properties: {http: {'content-type': 'json'}}" ], [ - MakeEvent(e => e.Properties = MakeProperties(("tags", new JArray("a", "b")))), + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("tags", new JArray("a", "b")))), "@Properties: {tags: ['a', 'b']}" ] ]; - - static EventEntity MakeEvent(Action? configure = null) - { - var evt = new EventEntity - { - Id = "event-1", - Timestamp = "2024-01-01T00:00:00.0000000Z", - RenderedMessage = "Hello", - MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello" }], - EventType = "$00000000", - }; - configure?.Invoke(evt); - return evt; - } - - static List MakeProperties(params (string Name, object? Value)[] items) => - items.Select(i => new EventPropertyPart(i.Name, i.Value)).ToList(); static string Render(EventEntity evt) { diff --git a/test/SeqCli.Tests/Support/Some.cs b/test/SeqCli.Tests/Support/Some.cs index 4d6f5e3b..7ba27d31 100644 --- a/test/SeqCli.Tests/Support/Some.cs +++ b/test/SeqCli.Tests/Support/Some.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; using Serilog.Events; using Serilog.Parsing; @@ -29,7 +32,7 @@ public static string String() public static string UriString() { - return "http://example.com"; + return "https://example.com"; } public static byte[] Bytes(int count) @@ -38,9 +41,21 @@ public static byte[] Bytes(int count) Rng.GetBytes(bytes); return bytes; } - - public static string ApiKey() + + public static EventEntity MakeEvent(Action? configure = null) { - return string.Join("", Bytes(8).Select(v => v.ToString("x2")).ToArray()); + var evt = new EventEntity + { + Id = $"event-{String()}", + Timestamp = "2024-01-01T00:00:00.0000000Z", + RenderedMessage = "Hello", + MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello" }], + EventType = "$00000000", + }; + configure?.Invoke(evt); + return evt; } + + public static List MakeProperties(params (string Name, object? Value)[] items) => + items.Select(i => new EventPropertyPart(i.Name, i.Value)).ToList(); } \ No newline at end of file From cb5ed21cb46b2d868692b0d229adaa63c0ad9205 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 13:02:24 +1000 Subject: [PATCH 20/27] Add seq_new_session endpoint to clear state. Assisted-by: Claude Opus 4.8 --- src/SeqCli/Mcp/McpSession.cs | 10 +++++++ .../Tools/Search/SearchAndQueryToolType.cs | 10 +++++++ .../Mcp/McpSessionBasicsTestCase.cs | 7 +++++ test/SeqCli.Tests/Mcp/McpSessionTests.cs | 26 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 26c75f7e..45a04c02 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -16,6 +16,16 @@ class McpSession readonly Dictionary _resultIdToEventId = new(); readonly Dictionary _eventIdToResult = new(); + public void Clear() + { + lock (_sync) + { + _resultIdToEventId.Clear(); + _eventIdToResult.Clear(); + // Note that `_nextId` is intentionally preserved. + } + } + public string ImportSearchResult(EventEntity evt) { lock (_sync) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index acfeff03..1b274e28 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -352,6 +352,16 @@ public async Task QueryAsync( }; } + [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] + [Description("Call this before interacting with Seq tools for the first time (optimizes resource usage by clearing caches).")] + public Task NewSessionAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + session.Clear(); + return Task.CompletedTask; + } + + static void FlattenResult(QueryResultPart result, Action> writeRow) { if (result.Error != null) diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index b57ba006..66dcdf2f 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -65,6 +65,13 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm new Dictionary { ["query"] = query })); Assert.Contains("Total", queryResult); Assert.Contains("6", queryResult); + + await client.CallToolAsync("seq_new_session"); + + var staleResult = await client.CallToolAsync( + "seq_read_search_result", + new Dictionary { ["result_id"] = resultIds[0] }); + Assert.True(staleResult.IsError ?? false); } static string AssertTextResult(CallToolResult callToolResult) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs index ac644a36..44c8dc5f 100644 --- a/test/SeqCli.Tests/Mcp/McpSessionTests.cs +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -136,4 +136,30 @@ public void EnumeratingUserPropertyNamesObservesCancellation() Assert.Throws( () => session.EnumerateUserPropertyNames(cts.Token).ToList()); } + + [Fact] + public void ClearForgetsImportedResults() + { + var session = new McpSession(); + var resultId = session.ImportSearchResult(Some.MakeEvent()); + + session.Clear(); + + Assert.False(session.TryGetSearchResult(resultId, out var result, out var error)); + Assert.Null(result); + Assert.NotNull(error); + Assert.Empty(session.EnumerateUserPropertyNames(CancellationToken.None)); + } + + [Fact] + public void ClearPreservesTheResultIdSequence() + { + var session = new McpSession(); + var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + + session.Clear(); + + var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + Assert.NotEqual(first, second); + } } From 3380f5fb1b7a471478207be0c924cd67a3ec6837 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 13:18:45 +1000 Subject: [PATCH 21/27] Factor some orthogonal API helpers out of the (crowded) tool class --- .../Mcp/Data/DataResourceGroupHelper.cs | 50 +++++++ src/SeqCli/Mcp/Data/QueryResultHelper.cs | 101 ++++++++++++++ .../Mcp/Formatting/SeqSyntaxFormatter.cs | 14 ++ src/SeqCli/Mcp/McpSession.cs | 14 ++ src/SeqCli/Mcp/Schema/EventEntitySchema.cs | 14 ++ .../Tools/Search/SearchAndQueryToolType.cs | 131 +++--------------- 6 files changed, 213 insertions(+), 111 deletions(-) create mode 100644 src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs create mode 100644 src/SeqCli/Mcp/Data/QueryResultHelper.cs diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs new file mode 100644 index 00000000..ff498d68 --- /dev/null +++ b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs @@ -0,0 +1,50 @@ +// 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.Globalization; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Seq.Api; +using Seq.Api.Model.Data; + +namespace SeqCli.Mcp.Data; + +public static class DataResourceGroupHelper +{ + static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + Culture = CultureInfo.InvariantCulture, + FloatParseHandling = FloatParseHandling.Decimal, + }); + + public static async Task QueryPreserveErrorResponsesAsync(SeqConnection connection, 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 request = new HttpRequestMessage + { + RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), 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))))!; + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Data/QueryResultHelper.cs b/src/SeqCli/Mcp/Data/QueryResultHelper.cs new file mode 100644 index 00000000..d5a5be53 --- /dev/null +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -0,0 +1,101 @@ +// 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.Data; + +namespace SeqCli.Mcp.Data; + +public static class QueryResultHelper +{ + public static void Flatten(QueryResultPart result, Action> writeRow) + { + if (result.Error != null) + return; + + if (result.Rows != null) + { + writeRow(result.Columns!); + foreach (var row in result.Rows) + { + writeRow(row); + } + } + else if (result.Slices != null) + { + writeRow(new object[] {"time"}.Concat(result.Columns!)); + + var empty = result.Columns!.Select(_ => "").ToArray(); + foreach (var slice in result.Slices) + { + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); + } + if (!any) + { + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); + } + } + } + else if (result.Series != null) + { + writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); + foreach (var series in result.Series) + { + foreach (var slice in series.Slices) + { + var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); + } + if (!any) + { + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); + } + } + } + } + else + { + throw new NotImplementedException("Query result set does not conform to any expected pattern."); + } + } + + static IEnumerable MergeColumns(string[] columns, TimeseriesPart? firstSeries) + { + if (firstSeries == null) + yield break; + + var i = 0; + for (; i < firstSeries.Key.Length; ++i) + { + yield return columns[i]; + } + + yield return "time"; + + for (; i < columns.Length; ++i) + { + yield return columns[i]; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 741ebed6..4a73939f 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.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.Collections.Generic; using System.Globalization; diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 45a04c02..02606f68 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.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.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs index 80312e00..d9b9f9d7 100644 --- a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.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.Collections.Generic; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 1b274e28..23520eeb 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -1,17 +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; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.IO; using System.Linq; -using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using Newtonsoft.Json; using Seq.Api; using Seq.Api.Client; using Seq.Api.Model.Data; @@ -20,6 +30,7 @@ using Seq.Syntax.Templates; using SeqCli.Cli.Commands; using SeqCli.Mapping; +using SeqCli.Mcp.Data; using SeqCli.Mcp.Formatting; using Serilog; using Serilog.Events; @@ -36,13 +47,6 @@ class SearchAndQueryToolType(McpSession session, SeqConnection connection) $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...\n{{#end}}" ); - static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings - { - DateParseHandling = DateParseHandling.None, - Culture = CultureInfo.InvariantCulture, - FloatParseHandling = FloatParseHandling.Decimal, - }); - [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + "a `result_id` of the form `R#####` which is valid in the current MCP session. Individual events can be " + @@ -263,14 +267,7 @@ public async Task QueryAsync( QueryResultPart result; try { - var request = new HttpRequestMessage - { - RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), - Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") - }; - var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); - result = Serializer.Deserialize( - new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; + result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, query, cancellationToken); } catch (Exception ex) { @@ -308,7 +305,7 @@ public async Task QueryAsync( var output = new StringWriter(); var first = true; - FlattenResult(result, row => + QueryResultHelper.Flatten(result, row => { if (first) { @@ -323,7 +320,6 @@ public async Task QueryAsync( output.Write(heading); } output.WriteLine(); - output.WriteLine(); } else { @@ -336,20 +332,12 @@ public async Task QueryAsync( output.Write(' '); SeqSyntaxFormatter.WriteValue(output, value); } - output.WriteLine(); } + + output.WriteLine(); }); - return new CallToolResult - { - Content = - [ - new TextContentBlock - { - Text = output.ToString() - } - ] - }; + return SimpleTextResult(output.ToString()); } [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] @@ -360,85 +348,6 @@ public Task NewSessionAsync(CancellationToken cancellationToken) session.Clear(); return Task.CompletedTask; } - - - static void FlattenResult(QueryResultPart result, Action> writeRow) - { - if (result.Error != null) - return; - - if (result.Rows != null) - { - writeRow(result.Columns!); - foreach (var row in result.Rows) - { - writeRow(row); - } - } - else if (result.Slices != null) - { - writeRow(new object[] {"time"}.Concat(result.Columns!)); - - var empty = result.Columns!.Select(_ => "").ToArray(); - foreach (var slice in result.Slices) - { - var any = false; - foreach (var row in slice.Rows) - { - any = true; - writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); - } - if (!any) - { - writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); - } - } - } - else if (result.Series != null) - { - writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); - foreach (var series in result.Series) - { - foreach (var slice in series.Slices) - { - var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); - var any = false; - foreach (var row in slice.Rows) - { - any = true; - writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); - } - if (!any) - { - writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); - } - } - } - } - else - { - throw new NotImplementedException("Query result set does not conform to any expected pattern."); - } - } - - static IEnumerable MergeColumns(IReadOnlyList columns, TimeseriesPart? firstSeries) - { - if (firstSeries == null) - yield break; - - var i = 0; - for (; i < firstSeries.Key.Length; ++i) - { - yield return columns[i]; - } - - yield return "time"; - - for (; i < columns.Count; ++i) - { - yield return columns[i]; - } - } static CallToolResult SimpleTextResult(string resultText, bool isError = false) { From 49158423e0645ef3a847a56bfd43dd415b45dece Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 15:54:44 +1000 Subject: [PATCH 22/27] Skill updates, add `mcp install` support for Qwen Code. Assisted-by: Claude Opus 4.8 --- src/SeqCli/Mcp/McpServerInstaller.cs | 9 ++ .../Resources/seq-search-and-query/SKILL.md | 82 ++++++++++++++++--- .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 9 ++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 60a0f23c..d9612102 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -56,6 +56,15 @@ static class McpServerInstaller "VS Code stores user-level MCP servers in settings.json; install into a project with `seqcli mcp install --agent vscode` instead.") : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), "servers"), + + // Qwen Code reads MCP servers from the `mcpServers` key of its `settings.json`, + // both user-global (`~/.qwen`) and per-project (`.qwen`) - not a standalone `mcp.json`. + ["qwen"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".qwen", + "settings.json"), + "mcpServers"), }; public static void Install(string? agent, bool global, string? profileName = null) 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 0d85fffd..95283d91 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -260,7 +260,7 @@ TimeGrouping = 'time' , '(' , duration , ')' ; HavingClause = 'having' , Expr ; OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; Ordering = TimeOrdering - | Expr , [ 'ci' ] , [ 'asc' | 'desc' ] ; + | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *) TimeOrdering = 'time' ; LimitClause = 'limit' , natural ; ForClause = 'for' , ForOption , { ',' , ForOption } ; @@ -270,9 +270,15 @@ ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains metric samples. -## Expression Examples +## Schema -| Example | Effect | +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. + +## Example Expressions + +| Example | Purpose~~~~ | |--------------------------------------------------------------|----------------------------------------------------------------------------------------------| | `@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. | @@ -282,22 +288,81 @@ metric samples. | `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | | `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | | `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | +| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | + +## Example Queries + +Grouped query with ordering: + +``` +select count(*) +from stream +group by @Resource.service.name as service +order by service +``` + +## Tracing Tactics + +Reconstruct a trace in execution (start-time) order: + +``` +select + @SpanId as span_id, + @ParentId as parent_id, + @Resource.service.name as service, + @Message as span_name, + @SpanKind as kind, + @Start as start, -- raw ticks — order by THIS column + ToIsoString(@Start) as start_iso, -- readable copy, for display only + TotalMilliseconds(@Elapsed) as ms +from stream +where @TraceId = '' and Has(@Start) +order by start asc +limit 1000 -- traces can be large; if the result looks truncated, raise this +``` + +This orders rows by start time; it does NOT build the hierarchy. The call tree is assembled from `parent_id` (each row's +`@ParentId` = its parent's `@SpanId`; the root's is null). + +Note that you'll need to use the "retrieve a specific trace span" search recipe to see more about a span appearing in +these results. + +Rank services by span latency over a window: + +``` +select + count(*) as spans, + Round(TotalMilliseconds(percentile(@Elapsed, 95)), 2) as p95_ms, + Round(TotalMilliseconds(max(@Elapsed)), 2) as max_ms +from stream +where @Timestamp >= now() - 30m and Has(@Start) +group by @Resource.service.name as service -- group key: alias it, do NOT put it in the select list +having spans > 50 -- filter groups by the select alias, NOT by count(*) directly +order by p95_ms desc -- order by a selected aggregate's alias +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. - 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 don'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 search result - schema tool. + - 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 (`ulong` with 100 ns + - `@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. @@ -313,8 +378,5 @@ metric samples. 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)`. - - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. - - 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. - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't re-specified. diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs index 7f9702eb..a099e9c3 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -51,6 +51,15 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Contains("\"--profile\"", profileConfig); Assert.Contains("\"Production\"", profileConfig); + // Qwen Code reads MCP servers from `mcpServers` in its `settings.json`, not an `mcp.json`. + var qwenExit = runner.Exec("mcp install -a qwen", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, qwenExit); + + var qwenConfig = File.ReadAllText(Path.Combine(tmp.Path, ".qwen/settings.json")); + Assert.Contains("\"mcpServers\"", qwenConfig); + Assert.Contains("\"seq\"", qwenConfig); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".qwen/mcp.json"))); + // VS Code has no supported user-global merge target. var vscodeGlobalExit = runner.Exec("mcp install -a vscode --global", disconnected: true, workingDirectory: tmp.Path); Assert.Equal(1, vscodeGlobalExit); From aa344ae2eb7d4cf1ba49eee9201cbdfb719914a5 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 16:05:21 +1000 Subject: [PATCH 23/27] Use the example trace id consistently --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 95283d91..6b286997 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -316,7 +316,7 @@ select ToIsoString(@Start) as start_iso, -- readable copy, for display only TotalMilliseconds(@Elapsed) as ms from stream -where @TraceId = '' and Has(@Start) +where @TraceId = '0af7651916cd43dd8448eb211c80319c' and Has(@Start) order by start asc limit 1000 -- traces can be large; if the result looks truncated, raise this ``` From 6dd69db3c64c50a4f31b344edb60f57b345e2c81 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 16:14:58 +1000 Subject: [PATCH 24/27] More skill tweaks --- .../Resources/seq-search-and-query/SKILL.md | 140 +++++++++--------- 1 file changed, 71 insertions(+), 69 deletions(-) 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 6b286997..5a260218 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -25,7 +25,7 @@ All events stored in Seq use the same data model. Spans are only distinguished f | `@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. | +| `@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. | | `@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. | @@ -62,63 +62,63 @@ The synthetic type name `any` is used as an alias for `null | boolean | number | These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. -| Function signature | Description | -|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | -| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | -| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | -| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | -| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `ToNumber(str: string): number?` | Parse string `str` as a number. | -| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | -| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | -| `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 `-` (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 `^` | 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 numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| 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 numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| 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 `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. | +| Function signature | Description | +|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `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 `-` (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 `^` | 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 `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. | ## Aggregate Functions @@ -278,17 +278,18 @@ tool** to inspect the actual properties appearing on search results, cross-refer ## Example Expressions -| Example | Purpose~~~~ | -|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@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%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | -| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | -| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | -| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | -| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | +| Example | Purpose~~~~ | +|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `@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. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | +| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | +| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | +| `@Level like 'err%' ci` | Perform a case-insensitive prefix search | ## Example Queries @@ -380,3 +381,4 @@ limit 100 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. From 40a1ae403cb0f75049a4713b8481b184f24e8303 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 16:17:49 +1000 Subject: [PATCH 25/27] More skill tweaks --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 5a260218..95bef11c 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -276,9 +276,12 @@ Seq servers are compatible with a vast array of data sources. They may use a mix 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. + ## Example Expressions -| Example | Purpose~~~~ | +| Example | Purpose | |--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| | `@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. | From 0e2de672b5d21e4289c58e1fb60bb7045e8221a9 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 1 Jun 2026 08:38:39 +1000 Subject: [PATCH 26/27] Update SKILL.md to remove deprecated operators --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 95bef11c..858902ef 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -166,7 +166,7 @@ regex_char = '\/' | ? any character except '/' ? ; ### Expression -``` +```ebnf Expr = Disjunction ; Disjunction = Conjunction , { 'or' , Conjunction } ; Conjunction = Comparison , { 'and' , Comparison } ; @@ -228,7 +228,7 @@ 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 +`is`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is the division operator. ### Queries From 9cf028874ed63ce5e9a8d188219503a319d169dc Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 1 Jun 2026 13:10:37 +1000 Subject: [PATCH 27/27] Review feedback, reduce SKILL.md token count by dropping out table/grammar whitespace --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 2 +- .../Cli/Commands/Skills/InstallCommand.cs | 4 +- src/SeqCli/Mcp/Data/QueryResultHelper.cs | 6 +- .../Resources/seq-search-and-query/SKILL.md | 346 +++++++++--------- .../Mcp/SeqSyntaxFormatterTests.cs | 118 +++--- 5 files changed, 229 insertions(+), 247 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 8d9b073c..88d3ab8c 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -37,7 +37,7 @@ public RunCommand() { _connection = Enable(); _storagePath = Enable(); - Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection.", + Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection", _ => _debug = true); } diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs index 3b67b63a..8b6c634e 100644 --- a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -29,12 +29,12 @@ public InstallCommand() { Options.Add( "g|global", - "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills.`", + "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills`", _ => _global = true); Options.Add( "a=|agent=", - "The agent name to install skills for; the default is the generic name `agents`.", + "The agent name to install skills for; the default is the generic name `agents`", t => _agent = ArgumentString.Normalize(t)); } diff --git a/src/SeqCli/Mcp/Data/QueryResultHelper.cs b/src/SeqCli/Mcp/Data/QueryResultHelper.cs index d5a5be53..613d1aa0 100644 --- a/src/SeqCli/Mcp/Data/QueryResultHelper.cs +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -20,7 +20,11 @@ namespace SeqCli.Mcp.Data; public static class QueryResultHelper -{ +{ + /// + /// Convert into a flat table. Seq reduces browser-side processing and optimizes + /// response sizes by constructing result trees for some grouped/time-sliced query results. + /// public static void Flatten(QueryResultPart result, Action> writeRow) { if (result.Error != null) 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 858902ef..d203729a 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -3,7 +3,7 @@ name: seq-search-and-query description: Search and query logs and spans in Seq. Use when interacting with Seq. license: Apache-2.0 metadata: - author: Datalust and Contributors + author: Datalust and Contributors --- Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to @@ -16,39 +16,39 @@ compute tabular, aggregate results from the same data. 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. -| 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. | -| `@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. | -| `@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`. | -| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | -| `@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. | -| `@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. | +| 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. | +| `@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. | +| `@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`. | +| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@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. | +| `@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. | ## Type System Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. -| Type name | Description | Example literals | -|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| -| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | -| **boolean** | The atoms `true` and `false`. | `true`, `false` | -| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | -| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | -| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | +| Type name | Description | Example literals | +|---|---|---| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **boolean** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | In expression evaluation, Seq does not perform any type coercion. @@ -62,106 +62,106 @@ The synthetic type name `any` is used as an alias for `null | boolean | number | These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. -| Function signature | Description | -|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `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. | -| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | -| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | -| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | -| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | -| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `ToNumber(str: string): number?` | Parse string `str` as a number. | -| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | -| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | -| `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 `-` (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 `^` | 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 `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. | +| Function signature | Description | +|---|---| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `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. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `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 `-` (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 `^` | 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 `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. | ## Aggregate Functions `select` queries (see grammar below) have access to the following aggregate functions. -| Aggregate function signature | Description | Example | -|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| -| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | -| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | -| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | -| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | -| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | -| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | -| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | -| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | -| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | -| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | -| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| Aggregate function signature | Description | Example | +|---|---|---| +| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | +| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | +| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | +| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | | `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | -| `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)` | +| `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 ### Base ```ebnf -identifier = ( letter | '_' ) , { letter | digit | '_' } ; +identifier = ( letter | '_' ) , { letter | digit | '_' } ; built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; -variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; letter = ? any Unicode letter ? ; -digit = ? any Unicode digit ? ; -string_literal = "'" , { string_char } , "'" ; -string_char = "''" | ? any character except single quote ? ; -number = natural , [ '.' , natural ] ; -hex_number = '0x' , hex_digit , { hex_digit } ; -natural = digit , { digit } ; -hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' - | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; -duration = { natural , time_unit }- ; -time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; -regular_expression = '/' , { regex_char } , '/' ; -regex_char = '\/' | ? any character except '/' ? ; +digit = ? any Unicode digit ? ; +string_literal = "'" , { string_char } , "'" ; +string_char = "''" | ? any character except single quote ? ; +number = natural , [ '.' , natural ] ; +hex_number = '0x' , hex_digit , { hex_digit } ; +natural = digit , { digit } ; +hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' + | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; +duration = { natural , time_unit }- ; +time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; +regular_expression = '/' , { regex_char } , '/' ; +regex_char = '\/' | ? any character except '/' ? ; ``` ### Expression @@ -172,57 +172,57 @@ Disjunction = Conjunction , { 'or' , Conjunction } ; Conjunction = Comparison , { 'and' , Comparison } ; Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; comparison_op = 'not' , 'like' - | 'like' - | 'not' , 'in' - | 'in' - | '<=' | '<>' | '<' - | '>=' | '>' - | '=' ; + | 'like' + | 'not' , 'in' + | 'in' + | '<=' | '<>' | '<' + | '>=' | '>' + | '=' ; Comparand = Term , { ( '+' | '-' ) , Term } ; Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; InnerTerm = Operand , { '^' , Operand } ; Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; -unary_op = '-' | 'not' ; +unary_op = '-' | 'not' ; null_test = 'null' | 'not' , 'null' ; Path = Factor , { path_step } ; path_step = '.' , identifier - | '[' , ( wildcard | Expr ) , ']' ; + | '[' , ( wildcard | Expr ) , ']' ; wildcard = '?' | '*' ; Factor = '(' , Expr , ')' - | Item ; + | Item ; Item = Property - | Literal - | Function - | ArrayLiteral - | ObjectLiteral - | Conditional - | Block - | Lambda - | Variable ; + | Literal + | Function + | ArrayLiteral + | ObjectLiteral + | Conditional + | Block + | Lambda + | Variable ; Property = built_in_identifier - | identifier ; (* when not followed by '(' *) + | identifier ; (* when not followed by '(' *) Literal = string_literal - | number - | hex_number - | duration - | regular_expression - | 'true' - | 'false' - | 'null' ; + | number + | hex_number + | duration + | regular_expression + | 'true' + | 'false' + | 'null' ; Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; function_name = identifier - | 'and' | 'not' | 'or' ; -arg_list = '*' (* only valid for count(*) *) - | [ Expr , { ',' , Expr } ] ; + | 'and' | 'not' | 'or' ; +arg_list = '*' (* only valid for count(*) *) + | [ Expr , { ',' , Expr } ] ; ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; ObjectMember = ( identifier | string_literal ) , ':' , Expr ; Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; -Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; -Binding = identifier , ':' , Expr ; -Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; -Variable = variable ; +Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; +Binding = identifier , ':' , Expr ; +Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; +Variable = variable ; ``` **Disambiguation:** The `/` character introduces a regular expression when it appears at the @@ -235,32 +235,32 @@ the division operator. ```ebnf Query = [ ExplainClause ] - SelectClause - [ IntoClause ] - [ FromClause ] - [ WhereClause ] - [ GroupByClause ] - [ HavingClause ] - [ OrderByClause ] - [ LimitClause ] - [ ForClause ] ; + SelectClause + [ IntoClause ] + [ FromClause ] + [ WhereClause ] + [ GroupByClause ] + [ HavingClause ] + [ OrderByClause ] + [ LimitClause ] + [ ForClause ] ; ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; SelectColumn = '*' - | Expr , [ 'as' , identifier ] ; + | Expr , [ 'as' , identifier ] ; IntoClause = 'into' , variable ; FromClause = 'from' , source , { LateralJoin } ; -source = 'stream' | 'series' ; +source = 'stream' | 'series' ; LateralJoin = 'lateral' , Expr , 'as' , identifier ; WhereClause = 'where' , Expr ; GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; -Grouping = TimeGrouping - | Expr , [ 'ci' ] , [ 'as' , identifier ] ; +Grouping = TimeGrouping + | Expr , [ 'ci' ] , [ 'as' , identifier ] ; TimeGrouping = 'time' , '(' , duration , ')' ; HavingClause = 'having' , Expr ; OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; -Ordering = TimeOrdering - | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *) +Ordering = TimeOrdering + | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *) TimeOrdering = 'time' ; LimitClause = 'limit' , natural ; ForClause = 'for' , ForOption , { ',' , ForOption } ; @@ -281,18 +281,18 @@ inconsistent! Use the schema tool at least once just to be safe. ## Example Expressions -| Example | Purpose | +| Example | Purpose | |--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@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). | +| `@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. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | -| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | -| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | -| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | -| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | -| `@Level like 'err%' ci` | Perform a case-insensitive prefix search | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | +| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | +| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | +| `@Level like 'err%' ci` | Perform a case-insensitive prefix search | ## Example Queries @@ -322,7 +322,7 @@ select from stream where @TraceId = '0af7651916cd43dd8448eb211c80319c' and Has(@Start) order by start asc -limit 1000 -- traces can be large; if the result looks truncated, raise this +limit 1000 -- traces can be large; if the result looks truncated, raise this ``` This orders rows by start time; it does NOT build the hierarchy. The call tree is assembled from `parent_id` (each row's diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs index 59e12ee9..0a4db67a 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -26,24 +26,27 @@ public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, boo } [Theory] - [MemberData(nameof(BuiltInPropertyCases))] - public void BuiltInPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + [MemberData(nameof(EventPropertyCases))] + public void EventPropertiesAreFormatted(EventEntity evt, string expectedLiteral) { Assert.Contains(expectedLiteral, Render(evt)); } - public static IEnumerable BuiltInPropertyCases() => + public static IEnumerable EventPropertyCases() => [ [Some.MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], - [Some.MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], + [Some.MakeEvent(e => e.Timestamp = "2024-01-01T00:00:00.0000000Z"), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], [Some.MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], [Some.MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], + [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], [ Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]), "@MessageTemplate: 'User {UserId}'" ], [Some.MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], + [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], + [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], [Some.MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], [Some.MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], [Some.MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], @@ -54,63 +57,10 @@ public static IEnumerable BuiltInPropertyCases() => [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], [Some.MakeEvent(e => e.Scope = Some.MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], [Some.MakeEvent(e => e.Resource = Some.MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], - [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"] - ]; - - [Theory] - [InlineData("@Exception")] - [InlineData("@Elapsed")] - [InlineData("@TraceId")] - [InlineData("@SpanId")] - [InlineData("@SpanKind")] - [InlineData("@Start")] - [InlineData("@ParentId")] - [InlineData("@Properties")] - [InlineData("@Scope")] - [InlineData("@Resource")] - [InlineData("@Definitions")] - public void OptionalPropertiesAreOmittedWhenAbsent(string token) - { - Assert.DoesNotContain(token, Render(Some.MakeEvent())); - } - - [Fact] - public void EmptyPropertyCollectionIsOmitted() - { - Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = []))); - } - - [Fact] - public void EventFormatIsAnObjectLiteral() - { - Assert.Equal( - "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " + - "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}", - Render(Some.MakeEvent(e => - { - e.Id = "event-1"; - e.Timestamp = "2024-01-02T00:00:00.0000002Z"; - e.RenderedMessage = "Hello!"; - e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }]; - e.EventType = "$00000001"; - }))); - } - - [Theory] - [MemberData(nameof(BasicPropertyFormattingCases))] - public void BasicPropertiesAreFormatted(EventEntity evt, string expectedLiteral) - { - Assert.Contains(expectedLiteral, Render(evt)); - } - - public static IEnumerable BasicPropertyFormattingCases() => - [ - [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], + [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"], [Some.MakeEvent(e => e.Level = null), "@Level: 'Information'"], [Some.MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" ], - [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], - [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" ], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], @@ -118,18 +68,7 @@ public static IEnumerable BasicPropertyFormattingCases() => [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("b", true))), "@Properties: {b: true}"], - [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"] - ]; - - [Theory] - [MemberData(nameof(NestedPropertyCases))] - public void NestedPropertiesAreFormatted(EventEntity evt, string expectedLiteral) - { - Assert.Contains(expectedLiteral, Render(evt)); - } - - public static IEnumerable NestedPropertyCases() => - [ + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"], [ Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }))), "@Resource: {service: {name: 'web'}}" @@ -160,6 +99,45 @@ public static IEnumerable NestedPropertyCases() => ] ]; + [Theory] + [InlineData("@Exception")] + [InlineData("@Elapsed")] + [InlineData("@TraceId")] + [InlineData("@SpanId")] + [InlineData("@SpanKind")] + [InlineData("@Start")] + [InlineData("@ParentId")] + [InlineData("@Properties")] + [InlineData("@Scope")] + [InlineData("@Resource")] + [InlineData("@Definitions")] + public void OptionalPropertiesAreOmittedWhenAbsent(string token) + { + Assert.DoesNotContain(token, Render(Some.MakeEvent())); + } + + [Fact] + public void EmptyPropertyCollectionIsOmitted() + { + Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = []))); + } + + [Fact] + public void EventFormatIsAnObjectLiteral() + { + Assert.Equal( + "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " + + "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}", + Render(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Timestamp = "2024-01-02T00:00:00.0000002Z"; + e.RenderedMessage = "Hello!"; + e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }]; + e.EventType = "$00000001"; + }))); + } + static string Render(EventEntity evt) { var output = new StringWriter();