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/ diff --git a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs new file mode 100644 index 00000000..8b1bed91 --- /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(), _global, _profile); + return Task.FromResult(0); + } +} diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs new file mode 100644 index 00000000..88d3ab8c --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -0,0 +1,85 @@ +// 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.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; + 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); + + if (_debug) + { + Log.Logger = new LoggerConfiguration() + .Enrich.WithProperty("Application", "seqcli mcp run") + .WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector())) + .CreateLogger(); + + Log.Information("Seq 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/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/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs new file mode 100644 index 00000000..8b6c634e --- /dev/null +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -0,0 +1,46 @@ +// 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.Skills; +using SeqCli.Util; + +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() + { + SkillInstaller.Install(_agent?.ToLowerInvariant(), _global); + return Task.FromResult(0); + } +} \ No newline at end of file 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/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..613d1aa0 --- /dev/null +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -0,0 +1,105 @@ +// 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 +{ + /// + /// 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) + 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 new file mode 100644 index 00000000..4a73939f --- /dev/null +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -0,0 +1,230 @@ +// 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; +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(); + + 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 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 WriteEvent(TextWriter output, EventEntity evt) + { + 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) + ); + } + + public 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}')"); + return; + } + + if (value is JArray ja) + { + var first = true; + 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))); + return; + } + + 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))); + } + + 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('}'); + } +} diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs new file mode 100644 index 00000000..d9612102 --- /dev/null +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -0,0 +1,118 @@ +// 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"), + + // 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) + { + agent ??= "agents"; + + 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/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs new file mode 100644 index 00000000..02606f68 --- /dev/null +++ b/src/SeqCli/Mcp/McpSession.cs @@ -0,0 +1,127 @@ +// 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; +using System.Threading; +using Seq.Api.Model.Events; +using System; +using System.Linq; +using SeqCli.Mcp.Schema; + +namespace SeqCli.Mcp; + +class McpSession +{ + readonly Lock _sync = new(); + int _nextId = 1; + 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) + { + 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); + } + } + + internal static string FormatResultId(int resultId) + { + return "R" + resultId.ToString("X5"); + } + + internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) + { + if (!formatted.StartsWith('R') || !int.TryParse(formatted[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 `R`, 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; + } + } + + public IEnumerable EnumerateUserPropertyNames(CancellationToken cancellationToken) + { + List all; + lock (_sync) + { + all = _eventIdToResult.Values.Select(pair => pair.Item2).ToList(); + } + + var seen = new HashSet(); + foreach (var evt in all) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var accessor in EventEntitySchema.EnumeratePropertyAccessorPaths(evt)) + { + if (seen.Add(accessor)) + { + yield return accessor; + } + } + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs new file mode 100644 index 00000000..d9b9f9d7 --- /dev/null +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -0,0 +1,61 @@ +// 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; +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 new file mode 100644 index 00000000..23520eeb --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -0,0 +1,366 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +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; +using SeqCli.Cli.Commands; +using SeqCli.Mapping; +using SeqCli.Mcp.Data; +using SeqCli.Mcp.Formatting; +using Serilog; +using Serilog.Events; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Search; + +[McpServerToolType] +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}}" + ); + + [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 " + + "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 SearchEventsAsync( + [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, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(predicate)) + { + 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); + } + + 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 SimpleTextResult($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", + isError: true); + } + } + + var resultsLock = new Lock(); + string? error = null; + var results = new List(); + var timeout = Task.Delay(TimeSpan.FromSeconds(45), 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, + render: true, + 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.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); + } + } + + }, cancellationToken); + + var completed = await Task.WhenAny(enumerate, timeout) == enumerate; + await cancelEnumerate.CancelAsync(); + + EventEntity[] takenResults; + string? 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}"; + } + } + 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 ReadSearchResultJsonAsync( + [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(SimpleTextResult(error, isError: true)); + } + + var resultText = new StringWriter(); + SeqSyntaxFormatter.WriteEvent(resultText, result); + + return Task.FromResult(SimpleTextResult(resultText.ToString())); + } + + [McpServerTool(Name = "seq_inspect_result_schema", ReadOnly = true, Title = "Inspect Search Result Schema")] + [Description("List the user-defined top-level, scope, and resource property names observed on events " + + "in search results so far in this session. Only events retrieved in search results are considered.")] + [return: Description("A list containing Seq syntax-formatted property names.")] + public Task InspectSchemaAsync(CancellationToken cancellationToken) + { + 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) + { + 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))) + { + 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 + { + result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, query, 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; + QueryResultHelper.Flatten(result, row => + { + if (first) + { + first = false; + var firstCol = true; + foreach (var heading in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + output.Write(heading); + } + output.WriteLine(); + } + else + { + var firstCol = true; + foreach (var value in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + SeqSyntaxFormatter.WriteValue(output, value); + } + } + + output.WriteLine(); + }); + + return SimpleTextResult(output.ToString()); + } + + [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 CallToolResult SimpleTextResult(string resultText, bool isError = false) + { + return new CallToolResult + { + IsError = isError, + Content = + [ + new TextContentBlock + { + Text = resultText + } + ] + }; + } +} \ 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 ce5d3ea0..afd80598 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -29,8 +29,12 @@ PreserveNewest + + PreserveNewest + + @@ -52,4 +56,7 @@ + + + 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..d203729a --- /dev/null +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -0,0 +1,387 @@ +--- +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 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)` | +| `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 + +```ebnf +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 ] ; +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 *) +TimeOrdering = 'time' ; +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. + +## Schema + +Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and +framework/ecosystem-specific property names, and may do so inconsistently. When exploring, **always use the MCP schema +tool** to inspect the actual properties appearing on search results, cross-referencing with source code where necessary. + +In particular, don't skip using the schema tool early in investigations just because you've seen a few events. Events are +inconsistent! Use the schema tool at least once just to be safe. + +## 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%' 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 + +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 = '0af7651916cd43dd8448eb211c80319c' 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 doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property + names from the built-ins described above, that appear on search results, or that are returned from the schema tool. + - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names + to be used. + - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an + embedded literal single quote. Backslash escaping is not recognized. + - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns + resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will + fail: use duration literals for durations, and the `DateTime` function + to convert from ISO-8601 strings. + - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use + JavaScript's system of comparisons. + - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. + - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. + - Regular expression evaluation is extremely expensive, avoid these as much as possible. + - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). + - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. + - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. + - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to + conserve resources when speculatively exploring. + - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, + you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. + - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't + re-specified. + - All function calls and operators are case-sensitive unless the `ci` modifier is appended. diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs new file mode 100644 index 00000000..57b96f9c --- /dev/null +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -0,0 +1,63 @@ +// 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? 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); + + foreach (var file in Directory.EnumerateFiles(source)) + { + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); + } + + foreach (var directory in Directory.EnumerateDirectories(source)) + { + CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory))); + } + } +} \ No newline at end of file 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.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs new file mode 100644 index 00000000..a099e9c3 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -0,0 +1,74 @@ +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); + + // 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); + + 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; + } +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs new file mode 100644 index 00000000..66dcdf2f --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +// ReSharper disable once UnusedType.Global +public partial class McpSessionBasicsTestCase : ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + 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); + + 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) + { + var text = string.Join("\n", callToolResult.Content.OfType().Select(c => c.Text)); + Assert.False(callToolResult.IsError ?? false, text); + return text; + } + + [GeneratedRegex("R[0-9a-zA-Z]+")] + private static partial Regex ResultIdRegex(); +} diff --git a/test/SeqCli.EndToEnd/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/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 diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs new file mode 100644 index 00000000..831ad5b9 --- /dev/null +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -0,0 +1,22 @@ +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 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; + } +} \ No newline at end of file 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) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs new file mode 100644 index 00000000..44c8dc5f --- /dev/null +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -0,0 +1,165 @@ +#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; + +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); + } + + [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()); + } + + [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); + } +} diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs new file mode 100644 index 00000000..0a4db67a --- /dev/null +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -0,0 +1,147 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using SeqCli.Mcp.Formatting; +using SeqCli.Tests.Support; +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); + } + + [Theory] + [MemberData(nameof(EventPropertyCases))] + public void EventPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable EventPropertyCases() => + [ + [Some.MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], + [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'"], + [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}"], + [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.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" + ], + [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}"], + [ + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }))), + "@Resource: {service: {name: 'web'}}" + ], + [ + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), + "@Resource: {service: {name: 'web', version: '1.0'}}" + ], + [ + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), + "@Resource: {service: {namespace: {name: 'web'}}}" + ], + [ + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), + "@Properties: {http: {request: {method: 'GET'}}}" + ], + [ + Some.MakeEvent(e => e.Scope = Some.MakeProperties(("db", new JObject { ["system"] = "postgres" }))), + "@Scope: {db: {system: 'postgres'}}" + ], + [ + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["content-type"] = "json" }))), + "@Properties: {http: {'content-type': 'json'}}" + ], + [ + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("tags", new JArray("a", "b")))), + "@Properties: {tags: ['a', 'b']}" + ] + ]; + + [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(); + SeqSyntaxFormatter.WriteEvent(output, evt); + return output.ToString(); + } +} 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