From 64808cace3d451b0445b4422639d9cba6d360ec9 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 08:28:50 +1000 Subject: [PATCH 01/98] stub out a simple metrics collector for the sampler --- src/Roastery/Metrics/ExponentialHistogram.cs | 53 +++++++++++++ src/Roastery/Metrics/RoasteryMetrics.cs | 82 ++++++++++++++++++++ src/Roastery/Program.cs | 19 ++++- src/Roastery/Web/RequestLoggingMiddleware.cs | 18 ++++- 4 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 src/Roastery/Metrics/ExponentialHistogram.cs create mode 100644 src/Roastery/Metrics/RoasteryMetrics.cs diff --git a/src/Roastery/Metrics/ExponentialHistogram.cs b/src/Roastery/Metrics/ExponentialHistogram.cs new file mode 100644 index 00000000..aac77124 --- /dev/null +++ b/src/Roastery/Metrics/ExponentialHistogram.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; + +namespace Roastery.Metrics; + +public class ExponentialHistogram +{ + public ExponentialHistogram(int initialScale = 20, int targetBuckets = 160) + { + _scale = initialScale; + _targetBuckets = targetBuckets; + _buckets = new Dictionary(); + } + + readonly int _targetBuckets; + + int _scale; + Dictionary _buckets; + + public void Record(double rawValue) + { + var midpoint = Midpoint(_scale, rawValue); + _buckets.TryAdd(midpoint, 0); + _buckets[midpoint] += 1; + + if (_buckets.Count <= _targetBuckets) return; + + // Rescale + var newScale = _scale - 1; + var newBuckets = new Dictionary(); + + foreach (var (oldMidpoint, count) in _buckets) + { + var newMidpoint = Midpoint(_scale, oldMidpoint); + newBuckets.TryAdd(newMidpoint, 0); + newBuckets[newMidpoint] += count; + } + + _buckets = newBuckets; + _scale = newScale; + } + + static double Midpoint(int scale, double rawValue) + { + var gamma = Math.Pow(2d, Math.Pow(2d, -scale)); + var index = Math.Abs(Math.Log(rawValue, gamma)); + + return (Math.Pow(gamma, index - 1) + Math.Pow(gamma, index)) / 2; + } + + public IReadOnlyDictionary Buckets => _buckets; + public int Scale => _scale; +} \ No newline at end of file diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs new file mode 100644 index 00000000..8db43ab7 --- /dev/null +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; + +namespace Roastery.Metrics; + +public class RoasteryMetrics +{ + readonly Lock _lock = new(); + + // Request Duration: Histogram + public record struct RequestDurationKey(string Path, int StatusCode); + Dictionary _requestDuration = new(); + + public record struct Sample( + Dictionary RequestDuration + ) + { + public IEnumerable ToEvents() + { + throw new NotImplementedException(); + } + } + + public void RecordRequestDuration(RequestDurationKey key, double rawValue) + { + lock (_lock) + { + if (!_requestDuration.TryGetValue(key, out var metric)) + { + metric = new ExponentialHistogram(); + _requestDuration.Add(key, metric); + } + + metric.Record(rawValue); + } + } + + public Sample Take() + { + var requestDuration = new Dictionary(); + + lock (_lock) + { + (requestDuration, _requestDuration) = (_requestDuration, requestDuration); + } + + return new Sample(requestDuration); + } + + public static Task PeriodicSample( + RoasteryMetrics metrics, + TimeSpan samplingInterval, + Func sample, + CancellationToken cancellationToken) + { + return Task.Run(async () => + { + var waitFor = samplingInterval; + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(waitFor, cancellationToken); + + var stopwatch = Stopwatch.StartNew(); + + try + { + await sample(metrics.Take(), cancellationToken); + } + catch + { + // Ignored + } + + var elapsed = stopwatch.Elapsed; + waitFor = elapsed < samplingInterval ? samplingInterval - stopwatch.Elapsed : samplingInterval; + } + }, cancellationToken); + } +} diff --git a/src/Roastery/Program.cs b/src/Roastery/Program.cs index 00f587f5..91accd70 100644 --- a/src/Roastery/Program.cs +++ b/src/Roastery/Program.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -6,6 +7,7 @@ using Roastery.Api; using Roastery.Data; using Roastery.Fake; +using Roastery.Metrics; using Roastery.Util; using Roastery.Web; using Serilog; @@ -17,15 +19,27 @@ public static class Program { public static async Task Main(ILogger logger, CancellationToken cancellationToken = default) { + var metrics = new RoasteryMetrics(); + var webApplicationLogger = logger.ForContext("Application", "Roastery Web Frontend"); + // Sample metrics + var periodicSample = RoasteryMetrics.PeriodicSample(metrics, TimeSpan.FromSeconds(5), (sample, ct) => + { + webApplicationLogger + .ForContext("Sample", sample, true) + .Information("Metrics sampled"); + + return Task.CompletedTask; + }, cancellationToken); + var database = new Database(webApplicationLogger, "roastery"); DatabaseMigrator.Populate(database); var client = new HttpClient( "https://roastery.datalust.co", new NetworkLatencyMiddleware( - new RequestLoggingMiddleware(webApplicationLogger, + new RequestLoggingMiddleware(webApplicationLogger, metrics, new SchedulingLatencyMiddleware( new FaultInjectionMiddleware(webApplicationLogger, new Router([ @@ -46,5 +60,6 @@ public static async Task Main(ILogger logger, CancellationToken cancellationToke agents.Add(new ArchivingBatch(client, batchApplicationLogger)); await Task.WhenAll(agents.Select(a => Agent.Run(a, cancellationToken))); + await periodicSample; } } \ No newline at end of file diff --git a/src/Roastery/Web/RequestLoggingMiddleware.cs b/src/Roastery/Web/RequestLoggingMiddleware.cs index f0cdb784..b9c2186f 100644 --- a/src/Roastery/Web/RequestLoggingMiddleware.cs +++ b/src/Roastery/Web/RequestLoggingMiddleware.cs @@ -1,6 +1,9 @@ using System; +using System.Diagnostics; +using System.Diagnostics.Metrics; using System.Net; using System.Threading.Tasks; +using Roastery.Metrics; using Serilog; using Serilog.Context; using Serilog.Events; @@ -12,23 +15,29 @@ class RequestLoggingMiddleware : HttpServer { readonly HttpServer _next; readonly ILogger _logger; - - public RequestLoggingMiddleware(ILogger logger, HttpServer next) + readonly RoasteryMetrics _metrics; + + public RequestLoggingMiddleware(ILogger logger, RoasteryMetrics metrics, HttpServer next) { _next = next; _logger = logger.ForContext(); + _metrics = metrics; } public override async Task InvokeAsync(HttpRequest request) { using var _ = LogContext.PushProperty("RequestId", request.RequestId); + var requestTiming = Stopwatch.StartNew(); using var activity = _logger.StartActivity("HTTP {RequestMethod} {RequestPath}", request.Method, request.Path); try { var response = await _next.InvokeAsync(request); + LogCompletion(activity, null, response.StatusCode); + _metrics.RecordRequestDuration(new RoasteryMetrics.RequestDurationKey(request.Path, (int)response.StatusCode), requestTiming.ElapsedMilliseconds); + return response; } catch (Exception ex1) when (LogCompletion(activity, ex1, HttpStatusCode.InternalServerError)) @@ -38,7 +47,10 @@ public override async Task InvokeAsync(HttpRequest request) } catch { - return new HttpResponse(HttpStatusCode.InternalServerError, "An error occurred."); + var statusCode = HttpStatusCode.InternalServerError; + + _metrics.RecordRequestDuration(new RoasteryMetrics.RequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); + return new HttpResponse(statusCode, "An error occurred."); } } From 4a532aa435a5516fdebe6e4aa8bba1462d65b9c9 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 11:39:43 +1000 Subject: [PATCH 02/98] fill in initial metric support in sample ingest --- src/Roastery/Metrics/ExponentialHistogram.cs | 14 ++- src/Roastery/Metrics/PropertyNameMapping.cs | 3 + src/Roastery/Metrics/RoasteryMetrics.cs | 97 +++++++++++++++---- src/Roastery/Program.cs | 11 ++- src/Roastery/Web/RequestLoggingMiddleware.cs | 4 +- src/SeqCli/Apps/Hosting/AppContainer.cs | 2 +- .../Cli/Commands/ApiKey/CreateCommand.cs | 2 +- src/SeqCli/Cli/Commands/IngestCommand.cs | 2 +- src/SeqCli/Cli/Commands/SearchCommand.cs | 2 +- src/SeqCli/Ingestion/JsonLogEventReader.cs | 2 +- .../{Levels => Mapping}/LevelMapping.cs | 2 +- src/SeqCli/Mapping/MetricsMapping.cs | 9 ++ src/SeqCli/Output/OutputFormatter.cs | 11 ++- src/SeqCli/PlainText/Extraction/Matchers.cs | 2 +- .../PlainText/LogEvents/LogEventBuilder.cs | 2 +- src/SeqCli/Sample/Loader/Simulation.cs | 4 +- .../PlainText/LogEventBuilderTests.cs | 1 - 17 files changed, 130 insertions(+), 40 deletions(-) create mode 100644 src/Roastery/Metrics/PropertyNameMapping.cs rename src/SeqCli/{Levels => Mapping}/LevelMapping.cs (99%) create mode 100644 src/SeqCli/Mapping/MetricsMapping.cs diff --git a/src/Roastery/Metrics/ExponentialHistogram.cs b/src/Roastery/Metrics/ExponentialHistogram.cs index aac77124..8e3db4bf 100644 --- a/src/Roastery/Metrics/ExponentialHistogram.cs +++ b/src/Roastery/Metrics/ExponentialHistogram.cs @@ -17,8 +17,16 @@ public ExponentialHistogram(int initialScale = 20, int targetBuckets = 160) int _scale; Dictionary _buckets; + double _min; + double _max; + ulong _total; + public void Record(double rawValue) { + _min = Math.Min(_min, rawValue); + _max = Math.Max(_max, rawValue); + _total += 1; + var midpoint = Midpoint(_scale, rawValue); _buckets.TryAdd(midpoint, 0); _buckets[midpoint] += 1; @@ -50,4 +58,8 @@ static double Midpoint(int scale, double rawValue) public IReadOnlyDictionary Buckets => _buckets; public int Scale => _scale; -} \ No newline at end of file + + public double Min => _min; + public double Max => _max; + public ulong Total => _total; +} diff --git a/src/Roastery/Metrics/PropertyNameMapping.cs b/src/Roastery/Metrics/PropertyNameMapping.cs new file mode 100644 index 00000000..bcd3fdfd --- /dev/null +++ b/src/Roastery/Metrics/PropertyNameMapping.cs @@ -0,0 +1,3 @@ +namespace Roastery.Metrics; + +public record struct PropertyNameMapping(string MetricDefinitions, string MetricSamples); diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 8db43ab7..1beafcae 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -1,59 +1,113 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Serilog; +using Serilog.Events; +using Serilog.Parsing; namespace Roastery.Metrics; public class RoasteryMetrics { - readonly Lock _lock = new(); + public class Sample + { + /* + Adding new metrics: - // Request Duration: Histogram - public record struct RequestDurationKey(string Path, int StatusCode); - Dictionary _requestDuration = new(); + 1. Add a new key type, `TKey` for the metric's attributes using structural equality. + 2. Add a `Dictionary` property for the metric where `TMetric` is its collection type. + 3. Add a method to `RoasterMetrics` to add a sample to the metric for a given key. + 4. Add support in `ToLogEvents` for the new metric. + */ + + // `http.request.duration`: Histogram + public record struct RequestDurationKey(string Path, int StatusCode); + public readonly Dictionary RequestDuration = new(); + + static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled"); - public record struct Sample( - Dictionary RequestDuration - ) - { - public IEnumerable ToEvents() + public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp) { - throw new NotImplementedException(); + foreach (var (key, metric) in RequestDuration) + { + var metricName = "http.request.duration"; + var metricDefinition = new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" }; + var sample = new + { + http = new + { + request = new + { + duration = new + { + buckets = metric.Buckets + .Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(), + scale = metric.Scale, + min = metric.Min, + max = metric.Max, + count = metric.Total + } + } + }, + path = key.Path, + statusCode = key.StatusCode + }; + + yield return ToLogEvent(logger, propertyNameMapping, timestamp, metricName, metricDefinition, sample); + } + } + + static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, string metricName, + object metricDefinition, object sample) + { + logger.BindProperty(propertyNameMapping.MetricDefinitions, new Dictionary { { metricName, metricDefinition } }, true, out var definitionsProperty); + logger.BindProperty(propertyNameMapping.MetricSamples, sample, true, out var sampleProperty); + + return new LogEvent(timestamp, LogEventLevel.Information, null, Template, + [definitionsProperty!, sampleProperty!]); } } + + // Access to the current sample is synchronized through a lock + // This is a simple way to implement deltas for arbitrary types + readonly Lock _lock = new(); + Sample _current = new(); - public void RecordRequestDuration(RequestDurationKey key, double rawValue) + public void RecordRequestDuration(Sample.RequestDurationKey key, double rawValue) { lock (_lock) { - if (!_requestDuration.TryGetValue(key, out var metric)) + if (!_current.RequestDuration.TryGetValue(key, out var metric)) { metric = new ExponentialHistogram(); - _requestDuration.Add(key, metric); + _current.RequestDuration.Add(key, metric); } - + metric.Record(rawValue); } } - public Sample Take() + public (DateTimeOffset, Sample) Take() { - var requestDuration = new Dictionary(); + var timestamp = DateTimeOffset.UtcNow; + + var current = new Sample(); lock (_lock) { - (requestDuration, _requestDuration) = (_requestDuration, requestDuration); + (current, _current) = (_current, current); } - return new Sample(requestDuration); + return (timestamp, current); } public static Task PeriodicSample( RoasteryMetrics metrics, TimeSpan samplingInterval, - Func sample, + Func sample, CancellationToken cancellationToken) { return Task.Run(async () => @@ -67,13 +121,16 @@ public static Task PeriodicSample( try { - await sample(metrics.Take(), cancellationToken); + var (timestamp, current) = metrics.Take(); + await sample(timestamp, current, cancellationToken); } catch { // Ignored } + // Account for the time taken to produce the sample when computing + // the next interval to wait for var elapsed = stopwatch.Elapsed; waitFor = elapsed < samplingInterval ? samplingInterval - stopwatch.Elapsed : samplingInterval; } diff --git a/src/Roastery/Program.cs b/src/Roastery/Program.cs index 91accd70..b181a699 100644 --- a/src/Roastery/Program.cs +++ b/src/Roastery/Program.cs @@ -17,18 +17,19 @@ namespace Roastery; // Named this way to make stack traces a little more believable :-) public static class Program { - public static async Task Main(ILogger logger, CancellationToken cancellationToken = default) + public static async Task Main(ILogger logger, PropertyNameMapping propertyNameMapping, CancellationToken cancellationToken = default) { var metrics = new RoasteryMetrics(); var webApplicationLogger = logger.ForContext("Application", "Roastery Web Frontend"); // Sample metrics - var periodicSample = RoasteryMetrics.PeriodicSample(metrics, TimeSpan.FromSeconds(5), (sample, ct) => + var periodicSample = RoasteryMetrics.PeriodicSample(metrics, TimeSpan.FromSeconds(5), (timestamp, sample, ct) => { - webApplicationLogger - .ForContext("Sample", sample, true) - .Information("Metrics sampled"); + foreach (var evt in sample.ToLogEvents(webApplicationLogger, propertyNameMapping, timestamp)) + { + webApplicationLogger.Write(evt); + } return Task.CompletedTask; }, cancellationToken); diff --git a/src/Roastery/Web/RequestLoggingMiddleware.cs b/src/Roastery/Web/RequestLoggingMiddleware.cs index b9c2186f..2ccc8884 100644 --- a/src/Roastery/Web/RequestLoggingMiddleware.cs +++ b/src/Roastery/Web/RequestLoggingMiddleware.cs @@ -36,7 +36,7 @@ public override async Task InvokeAsync(HttpRequest request) var response = await _next.InvokeAsync(request); LogCompletion(activity, null, response.StatusCode); - _metrics.RecordRequestDuration(new RoasteryMetrics.RequestDurationKey(request.Path, (int)response.StatusCode), requestTiming.ElapsedMilliseconds); + _metrics.RecordRequestDuration(new RoasteryMetrics.Sample.RequestDurationKey(request.Path, (int)response.StatusCode), requestTiming.ElapsedMilliseconds); return response; } @@ -49,7 +49,7 @@ public override async Task InvokeAsync(HttpRequest request) { var statusCode = HttpStatusCode.InternalServerError; - _metrics.RecordRequestDuration(new RoasteryMetrics.RequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); + _metrics.RecordRequestDuration(new RoasteryMetrics.Sample.RequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); return new HttpResponse(statusCode, "An error occurred."); } } diff --git a/src/SeqCli/Apps/Hosting/AppContainer.cs b/src/SeqCli/Apps/Hosting/AppContainer.cs index 72184492..90e05667 100644 --- a/src/SeqCli/Apps/Hosting/AppContainer.cs +++ b/src/SeqCli/Apps/Hosting/AppContainer.cs @@ -20,7 +20,7 @@ using Newtonsoft.Json.Linq; using Seq.Apps; using Seq.Apps.LogEvents; -using SeqCli.Levels; +using SeqCli.Mapping; using Serilog; using Serilog.Events; using Serilog.Formatting.Compact.Reader; diff --git a/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs index 437fe98a..5ebbfb08 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs @@ -22,7 +22,7 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Levels; +using SeqCli.Mapping; using SeqCli.Util; using Serilog; diff --git a/src/SeqCli/Cli/Commands/IngestCommand.cs b/src/SeqCli/Cli/Commands/IngestCommand.cs index d356212e..3eb2f822 100644 --- a/src/SeqCli/Cli/Commands/IngestCommand.cs +++ b/src/SeqCli/Cli/Commands/IngestCommand.cs @@ -20,7 +20,7 @@ using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Ingestion; -using SeqCli.Levels; +using SeqCli.Mapping; using SeqCli.PlainText; using SeqCli.Syntax; using Serilog; diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index adea7806..41a7539b 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -21,7 +21,7 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Levels; +using SeqCli.Mapping; using SeqCli.Util; using Serilog; using Serilog.Events; diff --git a/src/SeqCli/Ingestion/JsonLogEventReader.cs b/src/SeqCli/Ingestion/JsonLogEventReader.cs index 07a3ab85..7d5d9d90 100644 --- a/src/SeqCli/Ingestion/JsonLogEventReader.cs +++ b/src/SeqCli/Ingestion/JsonLogEventReader.cs @@ -18,7 +18,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using SeqCli.Levels; +using SeqCli.Mapping; using SeqCli.PlainText.Framing; using Serilog.Events; using Serilog.Formatting.Compact.Reader; diff --git a/src/SeqCli/Levels/LevelMapping.cs b/src/SeqCli/Mapping/LevelMapping.cs similarity index 99% rename from src/SeqCli/Levels/LevelMapping.cs rename to src/SeqCli/Mapping/LevelMapping.cs index f74b7523..99d16aa8 100644 --- a/src/SeqCli/Levels/LevelMapping.cs +++ b/src/SeqCli/Mapping/LevelMapping.cs @@ -16,7 +16,7 @@ using System.Collections.Generic; using Serilog.Events; -namespace SeqCli.Levels; +namespace SeqCli.Mapping; public static class LevelMapping { diff --git a/src/SeqCli/Mapping/MetricsMapping.cs b/src/SeqCli/Mapping/MetricsMapping.cs new file mode 100644 index 00000000..2cc979de --- /dev/null +++ b/src/SeqCli/Mapping/MetricsMapping.cs @@ -0,0 +1,9 @@ +using System; + +namespace SeqCli.Mapping; + +public static class MetricsMapping +{ + internal static readonly string SurrogateDefinitionsProperty = $"_SeqcliMetricDefinitions_{Guid.NewGuid():N}"; + internal static readonly string SurrogateSamplesProperty = $"_SeqcliMetricSamples_{Guid.NewGuid():N}"; +} diff --git a/src/SeqCli/Output/OutputFormatter.cs b/src/SeqCli/Output/OutputFormatter.cs index 9c0c2914..f90389d2 100644 --- a/src/SeqCli/Output/OutputFormatter.cs +++ b/src/SeqCli/Output/OutputFormatter.cs @@ -1,5 +1,5 @@ using SeqCli.Ingestion; -using SeqCli.Levels; +using SeqCli.Mapping; using Serilog.Formatting; using Serilog.Templates; using Serilog.Templates.Themes; @@ -12,7 +12,14 @@ static class OutputFormatter // the `@sp` property, because it needs to load on older Seq installs with older Serilog versions embedded in the // app runner. Once we've updated it, we can switch this to a Seq.Syntax template. internal static ITextFormatter Json(TemplateTheme? theme) => new ExpressionTemplate( - $"{{ {{@t, @mt, @l: coalesce({LevelMapping.SurrogateLevelProperty}, if @l = 'Information' then undefined() else @l), @x, @sp, @tr, @ps: coalesce({TraceConstants.ParentSpanIdProperty}, @ps), @st: coalesce({TraceConstants.SpanStartTimestampProperty}, @st), ..rest()}} }}\n", + $"{{ " + + $"if {MetricsMapping.SurrogateDefinitionsProperty} is not null then " + + // Emit a metric sample + $"{{@t, @l: undefined(), @d: {MetricsMapping.SurrogateDefinitionsProperty}, ..{MetricsMapping.SurrogateSamplesProperty}, ..rest()}} " + + $"else " + + // Emit a log or span + $"{{@t, @mt, @l: coalesce({LevelMapping.SurrogateLevelProperty}, if @l = 'Information' then undefined() else @l), @x, @sp, @tr, @ps: coalesce({TraceConstants.ParentSpanIdProperty}, @ps), @st: coalesce({TraceConstants.SpanStartTimestampProperty}, @st), ..rest()}} " + + $"}}\n", theme: theme ); } diff --git a/src/SeqCli/PlainText/Extraction/Matchers.cs b/src/SeqCli/PlainText/Extraction/Matchers.cs index a70623af..c2326f01 100644 --- a/src/SeqCli/PlainText/Extraction/Matchers.cs +++ b/src/SeqCli/PlainText/Extraction/Matchers.cs @@ -3,7 +3,7 @@ using System.Globalization; using System.Linq; using System.Reflection; -using SeqCli.Levels; +using SeqCli.Mapping; using SeqCli.PlainText.Parsers; using Superpower; using Superpower.Model; diff --git a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs index 365a490a..bab68619 100644 --- a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs +++ b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs @@ -17,7 +17,7 @@ using System.Diagnostics; using System.Globalization; using System.Linq; -using SeqCli.Levels; +using SeqCli.Mapping; using SeqCli.Util; using Serilog.Events; using Serilog.Parsing; diff --git a/src/SeqCli/Sample/Loader/Simulation.cs b/src/SeqCli/Sample/Loader/Simulation.cs index b3fc062f..60e9c060 100644 --- a/src/SeqCli/Sample/Loader/Simulation.cs +++ b/src/SeqCli/Sample/Loader/Simulation.cs @@ -14,8 +14,10 @@ using System.Threading; using System.Threading.Tasks; +using Roastery.Metrics; using Seq.Api; using SeqCli.Ingestion; +using SeqCli.Mapping; using Serilog; namespace SeqCli.Sample.Loader; @@ -37,7 +39,7 @@ public static async Task RunAsync(SeqConnection connection, string? apiKey, int var ship = Task.Run(() => LogShipper.ShipEventsAsync(connection, apiKey, buffer, InvalidDataHandling.Fail, SendFailureHandling.Continue, batchSize, null, cancellationToken), cancellationToken); - await Roastery.Program.Main(logger, cancellationToken); + await Roastery.Program.Main(logger, new PropertyNameMapping(MetricsMapping.SurrogateDefinitionsProperty, MetricsMapping.SurrogateSamplesProperty), cancellationToken); await logger.DisposeAsync(); await ship; } diff --git a/test/SeqCli.Tests/PlainText/LogEventBuilderTests.cs b/test/SeqCli.Tests/PlainText/LogEventBuilderTests.cs index 238680fc..75eaf8d5 100644 --- a/test/SeqCli.Tests/PlainText/LogEventBuilderTests.cs +++ b/test/SeqCli.Tests/PlainText/LogEventBuilderTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using SeqCli.Levels; using SeqCli.PlainText.LogEvents; using Serilog.Events; using Superpower.Model; From c7d6334049ffdc931a13bda8989655e5280fdf3a Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 13:45:44 +1000 Subject: [PATCH 03/98] add some counters for order status --- src/Roastery/Api/OrdersController.cs | 14 +++- src/Roastery/Api/ProductsController.cs | 5 +- src/Roastery/Metrics/RoasteryMetrics.cs | 95 ++++++++++++++++++------- src/Roastery/Program.cs | 4 +- src/Roastery/Web/Controller.cs | 7 +- 5 files changed, 91 insertions(+), 34 deletions(-) diff --git a/src/Roastery/Api/OrdersController.cs b/src/Roastery/Api/OrdersController.cs index ce0efecf..35c34eb5 100644 --- a/src/Roastery/Api/OrdersController.cs +++ b/src/Roastery/Api/OrdersController.cs @@ -2,6 +2,7 @@ using System.Net; using System.Threading.Tasks; using Roastery.Data; +using Roastery.Metrics; using Roastery.Model; using Roastery.Web; using Serilog; @@ -15,8 +16,8 @@ class OrdersController : Controller { readonly Database _database; - public OrdersController(ILogger logger, Database database) - : base(logger) + public OrdersController(ILogger logger, RoasteryMetrics metrics, Database database) + : base(logger, metrics) { _database = database; } @@ -41,7 +42,10 @@ public async Task Create(HttpRequest request) } await _database.InsertAsync(order); + + Metrics.RecordOrderCreated(); Log.Information("Created new order {OrderId} for customer {CustomerName}", order.Id, order.CustomerName); + return Json(order, HttpStatusCode.Created); } @@ -65,7 +69,11 @@ public async Task Update(HttpRequest request) if (order.Status == OrderStatus.PendingShipment) Log.Information("Order placed and ready for shipment"); else if (order.Status == OrderStatus.Shipped) - Log.Information("Order shipped to {CustomerName} at {ShippingAddress}", order.CustomerName, order.ShippingAddress); + { + Metrics.RecordOrderShipped(); + Log.Information("Order shipped to {CustomerName} at {ShippingAddress}", order.CustomerName, + order.ShippingAddress); + } else Log.Information("Order updated"); diff --git a/src/Roastery/Api/ProductsController.cs b/src/Roastery/Api/ProductsController.cs index 22710017..dacd5e2b 100644 --- a/src/Roastery/Api/ProductsController.cs +++ b/src/Roastery/Api/ProductsController.cs @@ -1,5 +1,6 @@ using System.Threading.Tasks; using Roastery.Data; +using Roastery.Metrics; using Roastery.Model; using Roastery.Web; using Serilog; @@ -12,8 +13,8 @@ class ProductsController : Controller { readonly Database _database; - public ProductsController(ILogger logger, Database database) - : base(logger) + public ProductsController(ILogger logger, RoasteryMetrics metrics, Database database) + : base(logger, metrics) { _database = database; } diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 1beafcae..76ecf2c0 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -23,48 +23,77 @@ public class Sample 4. Add support in `ToLogEvents` for the new metric. */ - // `http.request.duration`: Histogram + // `http.request.duration`: histogram public record struct RequestDurationKey(string Path, int StatusCode); public readonly Dictionary RequestDuration = new(); + // `orders.created`: counter + public ulong OrdersCreated; + + // `orders.shipped`: counter + public ulong OrdersShipped; + static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled"); public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp) { foreach (var (key, metric) in RequestDuration) { - var metricName = "http.request.duration"; - var metricDefinition = new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" }; - var sample = new - { - http = new + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary { - request = new + { "http.request.duration", new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" } } + }, + new + { + http = new { - duration = new + request = new { - buckets = metric.Buckets - .Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(), - scale = metric.Scale, - min = metric.Min, - max = metric.Max, - count = metric.Total + duration = new + { + buckets = metric.Buckets + .Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(), + scale = metric.Scale, + min = metric.Min, + max = metric.Max, + count = metric.Total + } } - } - }, - path = key.Path, - statusCode = key.StatusCode - }; - - yield return ToLogEvent(logger, propertyNameMapping, timestamp, metricName, metricDefinition, sample); + }, + path = key.Path, + statusCode = key.StatusCode + } + ); } + + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + { "orders.created", new { kind = "Counter", unit = "orders", description = "The total number of orders created in the system" } }, + { "orders.shipped", new { kind = "Counter", unit = "orders", description = "The total number of orders shipped in the system" } } + }, + new + { + orders = new + { + created = OrdersCreated, + shipped = OrdersShipped + } + } + ); } - static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, string metricName, - object metricDefinition, object sample) + static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, Dictionary definitions, object samples) { - logger.BindProperty(propertyNameMapping.MetricDefinitions, new Dictionary { { metricName, metricDefinition } }, true, out var definitionsProperty); - logger.BindProperty(propertyNameMapping.MetricSamples, sample, true, out var sampleProperty); + logger.BindProperty(propertyNameMapping.MetricDefinitions, definitions, true, out var definitionsProperty); + logger.BindProperty(propertyNameMapping.MetricSamples, samples, true, out var sampleProperty); return new LogEvent(timestamp, LogEventLevel.Information, null, Template, [definitionsProperty!, sampleProperty!]); @@ -90,6 +119,22 @@ public void RecordRequestDuration(Sample.RequestDurationKey key, double rawValue } } + public void RecordOrderCreated() + { + lock (_lock) + { + _current.OrdersCreated += 1; + } + } + + public void RecordOrderShipped() + { + lock (_lock) + { + _current.OrdersShipped += 1; + } + } + public (DateTimeOffset, Sample) Take() { var timestamp = DateTimeOffset.UtcNow; diff --git a/src/Roastery/Program.cs b/src/Roastery/Program.cs index b181a699..acef4206 100644 --- a/src/Roastery/Program.cs +++ b/src/Roastery/Program.cs @@ -44,8 +44,8 @@ public static async Task Main(ILogger logger, PropertyNameMapping propertyNameMa new SchedulingLatencyMiddleware( new FaultInjectionMiddleware(webApplicationLogger, new Router([ - new OrdersController(logger, database), - new ProductsController(logger, database) + new OrdersController(logger, metrics, database), + new ProductsController(logger, metrics, database) ], webApplicationLogger)))))); var agents = new List(); diff --git a/src/Roastery/Web/Controller.cs b/src/Roastery/Web/Controller.cs index 73aa8c77..a16cd83b 100644 --- a/src/Roastery/Web/Controller.cs +++ b/src/Roastery/Web/Controller.cs @@ -1,4 +1,5 @@ using System.Net; +using Roastery.Metrics; using Serilog; namespace Roastery.Web; @@ -6,10 +7,12 @@ namespace Roastery.Web; abstract class Controller { protected ILogger Log { get; } - - protected Controller(ILogger logger) + protected RoasteryMetrics Metrics { get; } + + protected Controller(ILogger logger, RoasteryMetrics metrics) { Log = logger.ForContext(GetType()); + Metrics = metrics; } protected static HttpResponse Json(object? body, HttpStatusCode statusCode = HttpStatusCode.OK) From 5f1b3b2ed72e7f0472d250c0498b28d2b828f9a2 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 13:47:59 +1000 Subject: [PATCH 04/98] align naming of request duration metric to conventions --- src/Roastery/Metrics/RoasteryMetrics.cs | 12 ++++++------ src/Roastery/Web/RequestLoggingMiddleware.cs | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 76ecf2c0..295dcfdd 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -24,8 +24,8 @@ public class Sample */ // `http.request.duration`: histogram - public record struct RequestDurationKey(string Path, int StatusCode); - public readonly Dictionary RequestDuration = new(); + public record struct HttpRequestDurationKey(string Path, int StatusCode); + public readonly Dictionary HttpRequestDuration = new(); // `orders.created`: counter public ulong OrdersCreated; @@ -37,7 +37,7 @@ public record struct RequestDurationKey(string Path, int StatusCode); public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp) { - foreach (var (key, metric) in RequestDuration) + foreach (var (key, metric) in HttpRequestDuration) { yield return ToLogEvent( logger, @@ -105,14 +105,14 @@ static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMappi readonly Lock _lock = new(); Sample _current = new(); - public void RecordRequestDuration(Sample.RequestDurationKey key, double rawValue) + public void RecordHttpRequestDuration(Sample.HttpRequestDurationKey key, double rawValue) { lock (_lock) { - if (!_current.RequestDuration.TryGetValue(key, out var metric)) + if (!_current.HttpRequestDuration.TryGetValue(key, out var metric)) { metric = new ExponentialHistogram(); - _current.RequestDuration.Add(key, metric); + _current.HttpRequestDuration.Add(key, metric); } metric.Record(rawValue); diff --git a/src/Roastery/Web/RequestLoggingMiddleware.cs b/src/Roastery/Web/RequestLoggingMiddleware.cs index 2ccc8884..3219152a 100644 --- a/src/Roastery/Web/RequestLoggingMiddleware.cs +++ b/src/Roastery/Web/RequestLoggingMiddleware.cs @@ -36,7 +36,7 @@ public override async Task InvokeAsync(HttpRequest request) var response = await _next.InvokeAsync(request); LogCompletion(activity, null, response.StatusCode); - _metrics.RecordRequestDuration(new RoasteryMetrics.Sample.RequestDurationKey(request.Path, (int)response.StatusCode), requestTiming.ElapsedMilliseconds); + _metrics.RecordHttpRequestDuration(new RoasteryMetrics.Sample.HttpRequestDurationKey(request.Path, (int)response.StatusCode), requestTiming.ElapsedMilliseconds); return response; } @@ -49,7 +49,7 @@ public override async Task InvokeAsync(HttpRequest request) { var statusCode = HttpStatusCode.InternalServerError; - _metrics.RecordRequestDuration(new RoasteryMetrics.Sample.RequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); + _metrics.RecordHttpRequestDuration(new RoasteryMetrics.Sample.HttpRequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); return new HttpResponse(statusCode, "An error occurred."); } } From 3b1c27de3c3258af17bba001d576f3cacb9ecd42 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 14:59:53 +1000 Subject: [PATCH 05/98] align metric names to Serilog conventions --- src/Roastery/Metrics/RoasteryMetrics.cs | 44 ++++++++++--------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 295dcfdd..9beb225c 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -23,14 +23,14 @@ public class Sample 4. Add support in `ToLogEvents` for the new metric. */ - // `http.request.duration`: histogram + // `HttpRequestDuration`: histogram public record struct HttpRequestDurationKey(string Path, int StatusCode); public readonly Dictionary HttpRequestDuration = new(); - // `orders.created`: counter + // `OrdersCreated`: counter public ulong OrdersCreated; - // `orders.shipped`: counter + // `OrdersShipped`: counter public ulong OrdersShipped; static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled"); @@ -45,27 +45,20 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro timestamp, new Dictionary { - { "http.request.duration", new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" } } + { "HttpRequestDuration", new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" } } }, new { - http = new - { - request = new - { - duration = new - { - buckets = metric.Buckets - .Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(), - scale = metric.Scale, - min = metric.Min, - max = metric.Max, - count = metric.Total - } - } + HttpRequestDuration = new { + buckets = metric.Buckets + .Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(), + scale = metric.Scale, + min = metric.Min, + max = metric.Max, + count = metric.Total }, - path = key.Path, - statusCode = key.StatusCode + key.Path, + key.StatusCode } ); } @@ -76,16 +69,13 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro timestamp, new Dictionary { - { "orders.created", new { kind = "Counter", unit = "orders", description = "The total number of orders created in the system" } }, - { "orders.shipped", new { kind = "Counter", unit = "orders", description = "The total number of orders shipped in the system" } } + { "OrdersCreated", new { kind = "Counter", unit = "orders", description = "The total number of orders created in the system" } }, + { "OrdersShipped", new { kind = "Counter", unit = "orders", description = "The total number of orders shipped in the system" } } }, new { - orders = new - { - created = OrdersCreated, - shipped = OrdersShipped - } + OrdersCreated, + OrdersShipped } ); } From ca100ecef6f50a6286a8cdca33a1785500c23f91 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 15:26:43 +1000 Subject: [PATCH 06/98] update to .NET 10 --- build/Build.Linux.ps1 | 2 +- build/Build.Windows.ps1 | 2 +- dockerfiles/seqcli/linux-arm64.Dockerfile | 4 ++-- dockerfiles/seqcli/linux-x64.Dockerfile | 6 +++--- src/Roastery/Roastery.csproj | 2 +- src/SeqCli/SeqCli.csproj | 12 ++++++------ src/SeqCli/Util/PasswordHash.cs | 3 +-- test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj | 2 +- test/SeqCli.Tests/SeqCli.Tests.csproj | 4 ++-- 9 files changed, 18 insertions(+), 19 deletions(-) diff --git a/build/Build.Linux.ps1 b/build/Build.Linux.ps1 index 21fca84a..1d3279b8 100644 --- a/build/Build.Linux.ps1 +++ b/build/Build.Linux.ps1 @@ -10,7 +10,7 @@ $version = Get-SemVer Write-Output "Building version $version" -$framework = "net9.0" +$framework = "net10.0" $image = "datalust/seqcli" $archs = @( @{ rid = "x64"; platform = "linux/amd64" }, diff --git a/build/Build.Windows.ps1 b/build/Build.Windows.ps1 index d18263e9..6fd0ff3e 100644 --- a/build/Build.Windows.ps1 +++ b/build/Build.Windows.ps1 @@ -12,7 +12,7 @@ $version = Get-SemVer Write-Output "Building version $version" -$framework = 'net9.0' +$framework = 'net10.0' function Clean-Output { diff --git a/dockerfiles/seqcli/linux-arm64.Dockerfile b/dockerfiles/seqcli/linux-arm64.Dockerfile index 68d201b3..9729a8ba 100644 --- a/dockerfiles/seqcli/linux-arm64.Dockerfile +++ b/dockerfiles/seqcli/linux-arm64.Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -12,7 +12,7 @@ RUN apt-get update \ zlib1g \ && rm -rf /var/lib/apt/lists/* -COPY src/SeqCli/bin/Release/net9.0/linux-arm64/publish /bin/seqcli +COPY src/SeqCli/bin/Release/net10.0/linux-arm64/publish /bin/seqcli ENTRYPOINT ["/bin/seqcli/seqcli"] diff --git a/dockerfiles/seqcli/linux-x64.Dockerfile b/dockerfiles/seqcli/linux-x64.Dockerfile index 308c2a37..6b454e21 100644 --- a/dockerfiles/seqcli/linux-x64.Dockerfile +++ b/dockerfiles/seqcli/linux-x64.Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -6,13 +6,13 @@ RUN apt-get update \ libc6 \ libgcc1 \ libgssapi-krb5-2 \ - libicu70 \ + libicu-dev \ libssl3 \ libstdc++6 \ zlib1g \ && rm -rf /var/lib/apt/lists/* -COPY src/SeqCli/bin/Release/net9.0/linux-x64/publish /bin/seqcli +COPY src/SeqCli/bin/Release/net10.0/linux-x64/publish /bin/seqcli ENTRYPOINT ["/bin/seqcli/seqcli"] diff --git a/src/Roastery/Roastery.csproj b/src/Roastery/Roastery.csproj index ea919e7c..8b6a076c 100644 --- a/src/Roastery/Roastery.csproj +++ b/src/Roastery/Roastery.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 1b417cd2..6d99f8a8 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -1,7 +1,7 @@  Exe - net9.0 + net10.0 seqcli ..\..\asset\SeqCli.ico win-x64;linux-x64;linux-musl-x64;osx-x64;linux-arm64;linux-musl-arm64;osx-arm64 @@ -37,13 +37,13 @@ - - - + + + - - + + diff --git a/src/SeqCli/Util/PasswordHash.cs b/src/SeqCli/Util/PasswordHash.cs index 91576624..925031eb 100644 --- a/src/SeqCli/Util/PasswordHash.cs +++ b/src/SeqCli/Util/PasswordHash.cs @@ -22,7 +22,6 @@ public static byte[] Calculate(string password, byte[] salt) if (password == null) throw new ArgumentNullException(nameof(password)); if (salt == null) throw new ArgumentNullException(nameof(salt)); - using var algorithm = new Rfc2898DeriveBytes(password, salt, HashIter, HashAlgorithmName.SHA512); - return algorithm.GetBytes(HashSize); + return Rfc2898DeriveBytes.Pbkdf2(password, salt, HashIter, HashAlgorithmName.SHA512, HashSize); } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj index 797cf88c..00eec61a 100644 --- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj +++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj @@ -2,7 +2,7 @@ Exe - net9.0 + net10.0 diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj index b15422c9..41b33c8d 100644 --- a/test/SeqCli.Tests/SeqCli.Tests.csproj +++ b/test/SeqCli.Tests/SeqCli.Tests.csproj @@ -1,9 +1,9 @@  - net9.0 + net10.0 - + all From eab25ef89bb8caf805c05831e7fe91688931484e Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 15:30:37 +1000 Subject: [PATCH 07/98] update some missed .NET version refs --- .github/workflows/ci.yml | 4 ++-- ci.global.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52a1c06..dfbd102e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Setup uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Build and Publish env: DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -48,7 +48,7 @@ jobs: - name: Setup uses: actions/setup-dotnet@v4 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Configure Docker run: | docker run --privileged --rm linuxkit/binfmt:bebbae0c1100ebf7bf2ad4dfb9dfd719cf0ef132 diff --git a/ci.global.json b/ci.global.json index 345f67e3..c2af57a3 100644 --- a/ci.global.json +++ b/ci.global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "9.0.306" + "version": "10.0.102" } } From 52cbdacfdc2f14b4ba1d0042859c1fb5085cd847 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Wed, 21 Jan 2026 15:43:03 +1000 Subject: [PATCH 08/98] update libicu on arm --- dockerfiles/seqcli/linux-arm64.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dockerfiles/seqcli/linux-arm64.Dockerfile b/dockerfiles/seqcli/linux-arm64.Dockerfile index 9729a8ba..8be10413 100644 --- a/dockerfiles/seqcli/linux-arm64.Dockerfile +++ b/dockerfiles/seqcli/linux-arm64.Dockerfile @@ -6,7 +6,7 @@ RUN apt-get update \ libc6 \ libgcc1 \ libgssapi-krb5-2 \ - libicu70 \ + libicu-dev \ libssl3 \ libstdc++6 \ zlib1g \ From 759ad880e480bc6f110b8b0979988cb37f512b97 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 5 Feb 2026 10:10:28 +1000 Subject: [PATCH 09/98] Use Sum rather than Counter for sample delta-temporality counter metric kind --- src/Roastery/Metrics/RoasteryMetrics.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 9beb225c..cc95234d 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -69,8 +69,8 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro timestamp, new Dictionary { - { "OrdersCreated", new { kind = "Counter", unit = "orders", description = "The total number of orders created in the system" } }, - { "OrdersShipped", new { kind = "Counter", unit = "orders", description = "The total number of orders shipped in the system" } } + { "OrderCreated", new { kind = "Sum", unit = "orders", description = "An order was created" } }, + { "OrderShipped", new { kind = "Sum", unit = "orders", description = "An order was shipped" } } }, new { From 1128e7a0db2ed980d6c05fae579c0dbbdd4b95f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81oskot?= Date: Thu, 19 Feb 2026 09:45:25 +0100 Subject: [PATCH 10/98] feat: Add `seqcli events delete` command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `events delete` command is modelled after the `seqcli search` and accepts date range as well as filter expressions. The `events delete` command is covered with e2e tests. The idea of adding delete command came up in this thread: - https://github.com/datalust/seq-tickets/discussions/2529 Signed-off-by: Mateusz Łoskot --- .../Cli/Commands/Events/DeleteCommand.cs | 78 +++++++++++++++++++ .../Events/EventsDeleteTestCase.cs | 34 ++++++++ .../EventsDeleteWithDateRangeAllTestCase.cs | 46 +++++++++++ .../EventsDeleteWithDateRangeNoneTestCase.cs | 46 +++++++++++ .../EventsDeleteWithDateRangeSomeTestCase.cs | 53 +++++++++++++ .../Events/EventsDeleteWithFilterTestCase.cs | 52 +++++++++++++ 6 files changed, 309 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Events/DeleteCommand.cs create mode 100644 test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeNoneTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeSomeTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Events/EventsDeleteWithFilterTestCase.cs diff --git a/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs b/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs new file mode 100644 index 00000000..2e9aa26e --- /dev/null +++ b/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs @@ -0,0 +1,78 @@ +// Copyright 2026 Datalust Pty Ltd 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 SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; +// ReSharper disable UnusedType.Global + +namespace SeqCli.Cli.Commands; + +[Command("events", "delete", "Delete log events that match a given date range or filter", + Example = "seqcli events delete --start \"2026-01-01T00:00:00Z\" --end \"2026-01-31T23:59:59Z\"")] +class DeleteCommand : Command +{ + readonly ConnectionFeature _connection; + readonly DateRangeFeature _range; + readonly SignalExpressionFeature _signal; + readonly StoragePathFeature _storagePath; + string? _filter; + + public DeleteCommand() + { + Options.Add( + "f=|filter=", + "A filter to apply to deletion, for example `Host = 'xmpweb-01.example.com'`", + v => _filter = v); + + _range = Enable(); + _storagePath = Enable(); + _signal = Enable(); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + string? filter = null; + if (!string.IsNullOrWhiteSpace(_filter)) + filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; + + await connection.Events.DeleteAsync( + null, + _signal.Signal, + filter, + _range.Start, + _range.End, + null); + + Log.Information("Deleted matching events"); + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not delete matching events: {ErrorMessage}", ex.Message); + return 1; + } + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs new file mode 100644 index 00000000..29b585c9 --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteTestCase.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Delete; + +public class EventsDeleteTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + + var inputFile = Path.Combine("Data", "events.clef"); + Assert.True(File.Exists(inputFile)); + + var exit = runner.Exec("ingest", $"-i {inputFile}"); + Assert.Equal(0, exit); + + var eventsBefore = await connection.Events.ListAsync(); + Assert.Equal(15, eventsBefore.Count); + + exit = runner.Exec("events delete"); + Assert.Equal(0, exit); + + var eventsAfter = await connection.Events.ListAsync(); + Assert.Empty(eventsAfter); + } +} diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs new file mode 100644 index 00000000..ed56ac08 --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeAllTestCase.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Delete; + +public class EventsDeleteWithDateRangeAllTestCase : ICliTestCase +{ + readonly TestDataFolder _testDataFolder; + + public EventsDeleteWithDateRangeAllTestCase(TestDataFolder testDataFolder) + { + _testDataFolder = testDataFolder; + } + + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = _testDataFolder.ItemPath("delete-date-range-all.clef"); + + var isoNow = DateTime.UtcNow.ToString("o"); + await File.WriteAllTextAsync(inputFile, + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":1}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":2}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":3}}"); + var exit = runner.Exec("ingest", $"-i \"{inputFile}\""); + Assert.Equal(0, exit); + + var eventsBefore = await connection.Events.ListAsync(); + Assert.Equal(3, eventsBefore.Count); + + var isoFrom = DateTime.UtcNow.AddDays(-1).ToString("o"); + var isoTo = DateTime.UtcNow.AddDays(1).ToString("o"); + exit = runner.Exec("events delete", $"--start={isoFrom} --end={isoTo}"); + Assert.Equal(0, exit); + + var eventsAfter = await connection.Events.ListAsync(); + Assert.Empty(eventsAfter); + } +} diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeNoneTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeNoneTestCase.cs new file mode 100644 index 00000000..f95a7fb6 --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeNoneTestCase.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Delete; + +public class EventsDeleteWithDateRangeNoneTestCase : ICliTestCase +{ + readonly TestDataFolder _testDataFolder; + + public EventsDeleteWithDateRangeNoneTestCase(TestDataFolder testDataFolder) + { + _testDataFolder = testDataFolder; + } + + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = _testDataFolder.ItemPath("delete-date-range-none.clef"); + + var isoNow = DateTime.UtcNow.ToString("o"); + await File.WriteAllTextAsync(inputFile, + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":1}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":2}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":3}}"); + var exit = runner.Exec("ingest", $"-i \"{inputFile}\""); + Assert.Equal(0, exit); + + var eventsBefore = await connection.Events.ListAsync(); + Assert.Equal(3, eventsBefore.Count); + + var isoFrom = DateTime.UtcNow.AddDays(-10).ToString("o"); + var isoTo = DateTime.UtcNow.AddDays(-5).ToString("o"); + exit = runner.Exec("events delete", $"--start={isoFrom} --end={isoTo}"); + Assert.Equal(0, exit); + + var eventsAfter = await connection.Events.ListAsync(); + Assert.Equal(3, eventsAfter.Count); + } +} diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeSomeTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeSomeTestCase.cs new file mode 100644 index 00000000..a5b0f65a --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteWithDateRangeSomeTestCase.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Delete; + +public class EventsDeleteWithDateRangeSomeTestCase : ICliTestCase +{ + readonly TestDataFolder _testDataFolder; + + public EventsDeleteWithDateRangeSomeTestCase(TestDataFolder testDataFolder) + { + _testDataFolder = testDataFolder; + } + + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = _testDataFolder.ItemPath("delete-date-range-none.clef"); + + var isoNow = DateTime.UtcNow.ToString("o"); + await File.WriteAllTextAsync(inputFile, + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":1}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":2}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":3}}"); + var exit = runner.Exec("ingest", $"-i \"{inputFile}\""); + Assert.Equal(0, exit); + + isoNow = DateTime.UtcNow.ToString("o"); + await File.WriteAllTextAsync(inputFile, + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":4}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":5}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":6}}"); + exit = runner.Exec("ingest", $"-i \"{inputFile}\""); + Assert.Equal(0, exit); + + var eventsBefore = await connection.Events.ListAsync(); + Assert.Equal(6, eventsBefore.Count); + + var isoTo = DateTime.UtcNow.AddDays(1).ToString("o"); + exit = runner.Exec("events delete", $"--start={isoNow} --end={isoTo}"); + Assert.Equal(0, exit); + + var eventsAfter = await connection.Events.ListAsync(); + Assert.Equal(3, eventsAfter.Count); + } +} diff --git a/test/SeqCli.EndToEnd/Events/EventsDeleteWithFilterTestCase.cs b/test/SeqCli.EndToEnd/Events/EventsDeleteWithFilterTestCase.cs new file mode 100644 index 00000000..b00a8b18 --- /dev/null +++ b/test/SeqCli.EndToEnd/Events/EventsDeleteWithFilterTestCase.cs @@ -0,0 +1,52 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Delete; + +public class EventsDeleteWithFilterTestCase : ICliTestCase +{ + readonly TestDataFolder _testDataFolder; + + public EventsDeleteWithFilterTestCase(TestDataFolder testDataFolder) + { + _testDataFolder = testDataFolder; + } + + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var inputFile = _testDataFolder.ItemPath("delete-with-filter.clef"); + + var isoNow = DateTime.UtcNow.ToString("o"); + await File.WriteAllTextAsync(inputFile, + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":1}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":2}}"); + var hostOne = "xmpweb-01.example.com"; + var exit = runner.Exec("ingest", $"-i \"{inputFile}\" -p \"host={hostOne}\""); + Assert.Equal(0, exit); + + isoNow = DateTime.UtcNow.ToString("o"); + await File.WriteAllTextAsync(inputFile, + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":3}}" + Environment.NewLine + + $"{{\"@t\":\"{isoNow}\",\"@mt\":\"Event {{N}}\",\"N\":4}}"); + var hostTwo = "xmpweb-02.example.com"; + exit = runner.Exec("ingest", $"-i \"{inputFile}\" -p \"host={hostTwo}\""); + Assert.Equal(0, exit); + + var eventsBefore = await connection.Events.ListAsync(); + Assert.Equal(4, eventsBefore.Count); + + exit = runner.Exec("events delete", $"--filter=\"host='{hostTwo}'\""); + Assert.Equal(0, exit); + + var eventsAfter = await connection.Events.ListAsync(); + Assert.Equal(2, eventsAfter.Count); + } +} From 25951103613997a33075876ea1e0b004f6e5d49b Mon Sep 17 00:00:00 2001 From: Ashley Mannix Date: Mon, 23 Feb 2026 11:00:52 +1000 Subject: [PATCH 11/98] fix up naming of order metrics --- src/Roastery/Metrics/PropertyNameMapping.cs | 2 +- src/Roastery/Metrics/RoasteryMetrics.cs | 69 ++++++++++++++------- src/SeqCli/Mapping/MetricsMapping.cs | 1 - src/SeqCli/Output/OutputFormatter.cs | 2 +- src/SeqCli/Properties/launchSettings.json | 6 +- src/SeqCli/Sample/Loader/Simulation.cs | 2 +- 6 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/Roastery/Metrics/PropertyNameMapping.cs b/src/Roastery/Metrics/PropertyNameMapping.cs index bcd3fdfd..28a7f28e 100644 --- a/src/Roastery/Metrics/PropertyNameMapping.cs +++ b/src/Roastery/Metrics/PropertyNameMapping.cs @@ -1,3 +1,3 @@ namespace Roastery.Metrics; -public record struct PropertyNameMapping(string MetricDefinitions, string MetricSamples); +public record struct PropertyNameMapping(string MetricDefinitions); diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index cc95234d..0e2a1e9f 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -27,11 +27,11 @@ public class Sample public record struct HttpRequestDurationKey(string Path, int StatusCode); public readonly Dictionary HttpRequestDuration = new(); - // `OrdersCreated`: counter - public ulong OrdersCreated; + // `OrderCreated`: counter + public ulong OrderCreated; - // `OrdersShipped`: counter - public ulong OrdersShipped; + // `OrderShipped`: counter + public ulong OrderShipped; static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled"); @@ -45,11 +45,16 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro timestamp, new Dictionary { - { "HttpRequestDuration", new { kind = "Exponential", unit = "ms", description = "The time taken to fully process a request" } } + [nameof(HttpRequestDuration)] = new + { + kind = "Exponential", + unit = "ms", + description = "The time taken to fully process a request" + } }, - new + new Dictionary { - HttpRequestDuration = new { + [nameof(HttpRequestDuration)] = new { buckets = metric.Buckets .Select(bucket => new { midpoint = bucket.Key, count = bucket.Value }).ToArray(), scale = metric.Scale, @@ -57,8 +62,8 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro max = metric.Max, count = metric.Total }, - key.Path, - key.StatusCode + [nameof(key.Path)] = key.Path, + [nameof(key.StatusCode)] = key.StatusCode } ); } @@ -69,24 +74,46 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro timestamp, new Dictionary { - { "OrderCreated", new { kind = "Sum", unit = "orders", description = "An order was created" } }, - { "OrderShipped", new { kind = "Sum", unit = "orders", description = "An order was shipped" } } + [nameof(OrderCreated)] = new + { + kind = "Sum", + unit = "order", + description = "An order was created" + }, + [nameof(OrderShipped)] = new + { + kind = "Sum", + unit = "order", + description = "An order was shipped" + } }, - new + new Dictionary { - OrdersCreated, - OrdersShipped + [nameof(OrderCreated)] = OrderCreated, + [nameof(OrderShipped)] = OrderShipped } ); } - static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, Dictionary definitions, object samples) + static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, Dictionary definitions, Dictionary samples) { - logger.BindProperty(propertyNameMapping.MetricDefinitions, definitions, true, out var definitionsProperty); - logger.BindProperty(propertyNameMapping.MetricSamples, samples, true, out var sampleProperty); + var properties = new List(); + + if (logger.BindProperty(propertyNameMapping.MetricDefinitions, definitions, true, + out var definitionsProperty)) + { + properties.Add(definitionsProperty); + } + + foreach (var (key, value) in samples) + { + if (logger.BindProperty(key, value, true, out var sample)) + { + properties.Add(sample); + } + } - return new LogEvent(timestamp, LogEventLevel.Information, null, Template, - [definitionsProperty!, sampleProperty!]); + return new LogEvent(timestamp, LogEventLevel.Information, null, Template, properties); } } @@ -113,7 +140,7 @@ public void RecordOrderCreated() { lock (_lock) { - _current.OrdersCreated += 1; + _current.OrderCreated += 1; } } @@ -121,7 +148,7 @@ public void RecordOrderShipped() { lock (_lock) { - _current.OrdersShipped += 1; + _current.OrderShipped += 1; } } diff --git a/src/SeqCli/Mapping/MetricsMapping.cs b/src/SeqCli/Mapping/MetricsMapping.cs index 2cc979de..fe104666 100644 --- a/src/SeqCli/Mapping/MetricsMapping.cs +++ b/src/SeqCli/Mapping/MetricsMapping.cs @@ -5,5 +5,4 @@ namespace SeqCli.Mapping; public static class MetricsMapping { internal static readonly string SurrogateDefinitionsProperty = $"_SeqcliMetricDefinitions_{Guid.NewGuid():N}"; - internal static readonly string SurrogateSamplesProperty = $"_SeqcliMetricSamples_{Guid.NewGuid():N}"; } diff --git a/src/SeqCli/Output/OutputFormatter.cs b/src/SeqCli/Output/OutputFormatter.cs index f90389d2..43ff994b 100644 --- a/src/SeqCli/Output/OutputFormatter.cs +++ b/src/SeqCli/Output/OutputFormatter.cs @@ -15,7 +15,7 @@ static class OutputFormatter $"{{ " + $"if {MetricsMapping.SurrogateDefinitionsProperty} is not null then " + // Emit a metric sample - $"{{@t, @l: undefined(), @d: {MetricsMapping.SurrogateDefinitionsProperty}, ..{MetricsMapping.SurrogateSamplesProperty}, ..rest()}} " + + $"{{@t, @l: undefined(), @d: {MetricsMapping.SurrogateDefinitionsProperty}, ..rest()}} " + $"else " + // Emit a log or span $"{{@t, @mt, @l: coalesce({LevelMapping.SurrogateLevelProperty}, if @l = 'Information' then undefined() else @l), @x, @sp, @tr, @ps: coalesce({TraceConstants.ParentSpanIdProperty}, @ps), @st: coalesce({TraceConstants.SpanStartTimestampProperty}, @st), ..rest()}} " + diff --git a/src/SeqCli/Properties/launchSettings.json b/src/SeqCli/Properties/launchSettings.json index f9a7cad5..e2330db1 100644 --- a/src/SeqCli/Properties/launchSettings.json +++ b/src/SeqCli/Properties/launchSettings.json @@ -1,9 +1,13 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "SeqCli": { + "SeqCli Forwarder": { "commandName": "Project", "commandLineArgs": "forwarder run --pre" + }, + "SeqCli Sample": { + "commandName": "Project", + "commandLineArgs": "sample ingest -y" } } } diff --git a/src/SeqCli/Sample/Loader/Simulation.cs b/src/SeqCli/Sample/Loader/Simulation.cs index 60e9c060..a54f0a28 100644 --- a/src/SeqCli/Sample/Loader/Simulation.cs +++ b/src/SeqCli/Sample/Loader/Simulation.cs @@ -39,7 +39,7 @@ public static async Task RunAsync(SeqConnection connection, string? apiKey, int var ship = Task.Run(() => LogShipper.ShipEventsAsync(connection, apiKey, buffer, InvalidDataHandling.Fail, SendFailureHandling.Continue, batchSize, null, cancellationToken), cancellationToken); - await Roastery.Program.Main(logger, new PropertyNameMapping(MetricsMapping.SurrogateDefinitionsProperty, MetricsMapping.SurrogateSamplesProperty), cancellationToken); + await Roastery.Program.Main(logger, new PropertyNameMapping(MetricsMapping.SurrogateDefinitionsProperty), cancellationToken); await logger.DisposeAsync(); await ship; } From 22df4633f7b4c45767fcaa5c95f588192bb8c70c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 23 Feb 2026 14:10:10 +1000 Subject: [PATCH 12/98] Added missing `.` in metric descriptions. --- src/Roastery/Metrics/RoasteryMetrics.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 0e2a1e9f..12a4c08a 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -49,7 +49,7 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro { kind = "Exponential", unit = "ms", - description = "The time taken to fully process a request" + description = "The time taken to fully process a request." } }, new Dictionary @@ -78,13 +78,13 @@ public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping pro { kind = "Sum", unit = "order", - description = "An order was created" + description = "An order was created." }, [nameof(OrderShipped)] = new { kind = "Sum", unit = "order", - description = "An order was shipped" + description = "An order was shipped." } }, new Dictionary From ec27789d5bb14f82fdeb4b6add81c1b15583a7cf Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 27 Mar 2026 09:43:14 +1000 Subject: [PATCH 13/98] Add note on environment variable support to README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f410135..d17e81be 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ seqcli config set -k connection.serverUrl -v https://your-seq-server seqcli config set -k connection.apiKey -v your-api-key ``` -The API key will be stored in your `SeqCli.json` configuration file; on Windows, this is encrypted using DPAPI; on Mac/Linux the key is stored in plain text unless an encryptor is defined in `encryption.encryptor`. As an alternative to storing the API key in configuration, it can be passed to each command via the `--apikey=` argument. +The API key will be stored in your `SeqCli.json` configuration file; on Windows, this is encrypted using DPAPI; on Mac/Linux the key is stored in plain text unless an encryptor is defined in `encryption.encryptor`. As an alternative to storing the server URL and API key in configuration, they can be passed to each command via the `--server=` and `--apikey=` arguments, or in the `SEQCLI_CONNECTION_SERVERURL` and `SEQCLI_CONNECTION_APIKEY` environment variables. `seqcli` is also available as a Docker container under [`datalust/seqcli`](https://store.docker.com/community/images/datalust/seqcli): From 1f25ceded186079dace4f198263eb041638a277c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 27 Mar 2026 10:01:39 +1000 Subject: [PATCH 14/98] Give test processes more time to exit cleanly --- test/SeqCli.EndToEnd/Support/CaptiveProcess.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs index fb11e841..c9b6ed1d 100644 --- a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs +++ b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs @@ -111,11 +111,12 @@ public int WaitForExit(TimeSpan? timeout = null) if (_captureOutput) { - if (!_outputComplete.WaitOne(TimeSpan.FromSeconds(5))) - throw new IOException("STDOUT did not complete in the fixed 5-second window."); + const int secondsWait = 15; + if (!_outputComplete.WaitOne(TimeSpan.FromSeconds(secondsWait))) + throw new IOException($"STDOUT did not complete in the fixed {secondsWait}-second window."); - if (!_errorComplete.WaitOne(TimeSpan.FromSeconds(5))) - throw new IOException("STDERR did not complete in the fixed 5-second window."); + if (!_errorComplete.WaitOne(TimeSpan.FromSeconds(secondsWait))) + throw new IOException($"STDERR did not complete in the fixed {secondsWait}-second window."); } return _process.ExitCode; From 1f0b5d4c1d99bc5b99170b004c8c8609afc21e09 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 27 Mar 2026 11:10:21 +1000 Subject: [PATCH 15/98] .NET SDK update Need to trigger a clean CI run now that publishing has been sorted out. --- ci.global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci.global.json b/ci.global.json index c2af57a3..ce67766b 100644 --- a/ci.global.json +++ b/ci.global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.102" + "version": "10.0.201" } } From f96c300dc514f1c6f0209fb0441068d943c78b63 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 27 Mar 2026 11:26:52 +1000 Subject: [PATCH 16/98] Whitespace edit to force a fresh CI run --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d17e81be..e24a0b7f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,10 @@ seqcli config set -k connection.serverUrl -v https://your-seq-server seqcli config set -k connection.apiKey -v your-api-key ``` -The API key will be stored in your `SeqCli.json` configuration file; on Windows, this is encrypted using DPAPI; on Mac/Linux the key is stored in plain text unless an encryptor is defined in `encryption.encryptor`. As an alternative to storing the server URL and API key in configuration, they can be passed to each command via the `--server=` and `--apikey=` arguments, or in the `SEQCLI_CONNECTION_SERVERURL` and `SEQCLI_CONNECTION_APIKEY` environment variables. +The API key will be stored in your `SeqCli.json` configuration file; on Windows, this is encrypted using DPAPI; on Mac/Linux the key is stored in +plain text unless an encryptor is defined in `encryption.encryptor`. As an alternative to storing the server URL and API key in configuration, +they can be passed to each command via the `--server=` and `--apikey=` arguments, or in the `SEQCLI_CONNECTION_SERVERURL` and +`SEQCLI_CONNECTION_APIKEY` environment variables. `seqcli` is also available as a Docker container under [`datalust/seqcli`](https://store.docker.com/community/images/datalust/seqcli): From 4d541c82246964be1a05459ab8e9e43517ba4347 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Sat, 28 Mar 2026 07:50:29 +1000 Subject: [PATCH 17/98] track active readers/appenders in test filesystem --- .../Filesystem/InMemoryStoreDirectory.cs | 13 ++++++++- .../Forwarder/Filesystem/InMemoryStoreFile.cs | 27 +++++++++++++++++++ .../Filesystem/InMemoryStoreFileAppender.cs | 1 + .../Filesystem/InMemoryStoreFileReader.cs | 1 + 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs index 00b1762d..618f24fe 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs @@ -30,13 +30,24 @@ public InMemoryStoreFile Create(string name, Span contents) public override bool TryDelete(string name) { + var existing = _files[name]; + + if (!existing.CanDelete()) + { + throw new InvalidOperationException($"Cannot delete {name} with active handles"); + } + return _files.Remove(name); } public override InMemoryStoreFile Replace(string toReplace, string replaceWith) { _files[toReplace] = _files[replaceWith]; - _files.Remove(replaceWith); + + if (!TryDelete(toReplace)) + { + throw new InvalidOperationException($"Failed to replace {toReplace} with {replaceWith}"); + } return _files[toReplace]; } diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs index 26be3beb..e06579f9 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs @@ -10,6 +10,9 @@ class InMemoryStoreFile : StoreFile { public byte[] Contents { get; private set; } = Array.Empty(); + int _activeReaders; + int _activeWriters; + public override bool TryGetLength([NotNullWhen(true)] out long? length) { length = Contents.Length; @@ -29,12 +32,36 @@ public void Append(Span incoming) public override bool TryOpenRead(long length, [NotNullWhen(true)] out StoreFileReader? reader) { reader = new InMemoryStoreFileReader(this, (int)length); + _activeReaders++; + return true; } + internal void CloseReader() + { + _activeReaders--; + } + public override bool TryOpenAppend([NotNullWhen(true)] out StoreFileAppender? appender) { + if (_activeWriters != 0) + { + throw new InvalidOperationException("Attempt to open multiple appenders to the same file"); + } + appender = new InMemoryStoreFileAppender(this); + _activeWriters++; + return true; } + + internal void CloseAppender() + { + _activeWriters--; + } + + internal bool CanDelete() + { + return _activeReaders == 0 && _activeWriters == 0; + } } diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs index 9b078eec..a456e91c 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileAppender.cs @@ -35,5 +35,6 @@ public override void Sync() public override void Dispose() { + _storeFile.CloseAppender(); } } diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs index d8507e6c..06a83a1e 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFileReader.cs @@ -25,5 +25,6 @@ public override long CopyTo(Span buffer, long from = 0, long? length = nul public override void Dispose() { + _storeFile.CloseReader(); } } From e3fb80d375e3ea616285bafdb6dc1a8d28ef03d2 Mon Sep 17 00:00:00 2001 From: KodrAus Date: Sat, 28 Mar 2026 19:39:16 +1000 Subject: [PATCH 18/98] return correct file when replacing in tests --- .../SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs index 618f24fe..685e8375 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs @@ -49,7 +49,7 @@ public override InMemoryStoreFile Replace(string toReplace, string replaceWith) throw new InvalidOperationException($"Failed to replace {toReplace} with {replaceWith}"); } - return _files[toReplace]; + return _files[replaceWith]; } public override IEnumerable<(string Name, StoreFile File)> List(Func predicate) From d8f6d59f582950b0999a3515f54f5c36af3e1dd1 Mon Sep 17 00:00:00 2001 From: Ashley Mannix Date: Mon, 30 Mar 2026 15:39:55 +1000 Subject: [PATCH 19/98] rework chunk filling logic, fixing multiple issues --- .../Forwarder/Filesystem/StoreDirectory.cs | 21 +- src/SeqCli/Forwarder/Filesystem/StoreFile.cs | 17 + .../Filesystem/System/SystemStoreDirectory.cs | 6 +- src/SeqCli/Forwarder/Storage/BufferReader.cs | 167 +++++--- .../Storage/BufferReaderChunkExtents.cs | 20 +- .../Filesystem/InMemoryStoreDirectory.cs | 12 +- .../Forwarder/Filesystem/InMemoryStoreFile.cs | 2 +- .../Forwarder/Filesystem/TestFilesystem.cs | 37 ++ .../Forwarder/Storage/BookmarkTests.cs | 80 ++-- .../Forwarder/Storage/BufferTests.cs | 400 ++++++++++-------- 10 files changed, 469 insertions(+), 293 deletions(-) create mode 100644 test/SeqCli.Tests/Forwarder/Filesystem/TestFilesystem.cs diff --git a/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs b/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs index f6f34d36..bc5d018f 100644 --- a/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs +++ b/src/SeqCli/Forwarder/Filesystem/StoreDirectory.cs @@ -34,6 +34,15 @@ protected virtual (string, StoreFile) CreateTemporary() var tmpName = $"rc{Guid.NewGuid():N}.tmp"; return (tmpName, Create(tmpName)); } + + /// + /// Create a new file with the given contents. + /// + public virtual StoreFile Create(string name, Span contents) + { + Create(name); + return ReplaceContents(name, contents); + } /// /// Delete a file with the given name, returning whether the file was deleted. @@ -43,7 +52,7 @@ protected virtual (string, StoreFile) CreateTemporary() /// /// Atomically replace the contents of one file with another, creating it if it doesn't exist and deleting the other. /// - public abstract StoreFile Replace(string toReplace, string replaceWith); + public abstract StoreFile Replace(string destinationPath, string sourcePath); /// /// Atomically replace the contents of a file. @@ -73,10 +82,18 @@ public virtual StoreFile ReplaceContents(string name, Span contents, bool } /// - /// List all files in unspecified order. + /// List all files matching the given predicate in unspecified order. /// public abstract IEnumerable<(string Name, StoreFile File)> List(Func predicate); + /// + /// List all files in unspecified order. + /// + public virtual IEnumerable<(string Name, StoreFile File)> ListAll() + { + return List(_ => true); + } + /// /// Try get a file by name. /// diff --git a/src/SeqCli/Forwarder/Filesystem/StoreFile.cs b/src/SeqCli/Forwarder/Filesystem/StoreFile.cs index 1beed35f..5a189d5a 100644 --- a/src/SeqCli/Forwarder/Filesystem/StoreFile.cs +++ b/src/SeqCli/Forwarder/Filesystem/StoreFile.cs @@ -43,6 +43,23 @@ public virtual long CopyContentsTo(Span buffer) return reader.CopyTo(buffer); } + /// + /// Append the contents of the supplied buffer to the end of the file. + /// + public virtual void Append(Span buffer) + { + if (!TryOpenAppend(out var opened)) + { + throw new Exception("Failed to open the file for appending"); + } + + using var appender = opened; + + appender.Append(buffer); + appender.Commit(); + appender.Sync(); + } + /// /// Try open a reader to the file. /// diff --git a/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs index 695666d0..85c7e058 100644 --- a/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs +++ b/src/SeqCli/Forwarder/Filesystem/System/SystemStoreDirectory.cs @@ -102,11 +102,11 @@ public override bool TryDelete(string name) } } - public override SystemStoreFile Replace(string toReplace, string replaceWith) + public override SystemStoreFile Replace(string destinationPath, string sourcePath) { - var filePath = Path.Combine(_directoryPath, toReplace); + var filePath = Path.Combine(_directoryPath, destinationPath); - File.Replace(Path.Combine(_directoryPath, replaceWith), filePath, null); + File.Replace(Path.Combine(_directoryPath, sourcePath), filePath, null); return new SystemStoreFile(filePath); } diff --git a/src/SeqCli/Forwarder/Storage/BufferReader.cs b/src/SeqCli/Forwarder/Storage/BufferReader.cs index 7a4721c9..14d605b9 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReader.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReader.cs @@ -71,80 +71,107 @@ Scan through the offending chunk until a newline delimiter is found. 2. After discarding, attempt to fill a buffer with as much data as possible from the underlying chunks. */ + + var chunkIndex = 0; if (_discardingHead != null) { + // We're discarding an oversize payload var discardingRentedArray = ArrayPool.Shared.Rent(maxSize); // NOTE: We don't use `maxSize` here, because we're discarding these bytes // so it doesn't matter what size the target array is var discardingBatchBuffer = discardingRentedArray.AsSpan(); - while (_discardingHead != null) + while (_discardingHead != null && chunkIndex < _sortedChunks.Count) { - var chunk = _sortedChunks[0]; + var chunk = _sortedChunks[chunkIndex]; - // If the chunk has changed (it may have been deleted externally) + // If the first chunk has changed (it may have been deleted externally) // then stop discarding if (chunk.Name.Id != _discardingHead.Value.ChunkId) { _discardingHead = null; - ArrayPool.Shared.Return(discardingRentedArray); break; } - var chunkHead = Extents(chunk); + // Try read to the end of the chunk + // + // If reading the chunk length fails then advance over it + if (!chunk.Chunk.TryGetLength(out var length)) + { + chunkIndex += 1; + + continue; + } + + var chunkHead = new BufferReaderChunkExtents(Math.Min(length.Value, _discardingHead.Value.Offset), length.Value); // Attempt to fill the buffer with data from the underlying chunk + // + // If reading from the chunk fails then advance over it if (!TryFillChunk(chunk, - chunkHead with { CommitHead = _discardingHead.Value.Offset }, + chunkHead, discardingBatchBuffer, - out var fill)) + out var filled)) { - // If attempting to read from the chunk fails then remove it and carry on - // This is also done below in the regular read-loop if reading fails - _sortedChunks.RemoveAt(0); - _discardingHead = null; + chunkIndex += 1; - ArrayPool.Shared.Return(discardingRentedArray); - break; + continue; } // Scan forwards for the next newline - var firstNewlineIndex = discardingBatchBuffer[..fill.Value].IndexOf((byte)'\n'); - - // If a newline was found then advance the reader to it and stop discarding - if (firstNewlineIndex >= 0) fill = firstNewlineIndex + 1; + var firstNewlineIndex = discardingBatchBuffer[..filled.Value].IndexOf((byte)'\n'); + if (firstNewlineIndex >= 0) filled = firstNewlineIndex + 1; _discardingHead = _discardingHead.Value with { - Offset = _discardingHead.Value.Offset + fill.Value + Offset = _discardingHead.Value.Offset + filled.Value }; _readHead = _discardingHead.Value; - var isChunkFinished = _discardingHead.Value.Offset == chunkHead.WriteHead; - - // If the chunk is finished or a newline is found then stop discarding - if (firstNewlineIndex >= 0 || (isChunkFinished && _sortedChunks.Count > 1)) + // If a newline was found then advance the reader to it and stop discarding + if (firstNewlineIndex >= 0) { _discardingHead = null; - ArrayPool.Shared.Return(discardingRentedArray); break; } + + var isChunkFinished = chunkHead.CommitHead + filled == chunkHead.WriteHead; + + // If we've discarded to the end of the chunk then update our state from the disk and return + // + // The next time we attempt to fill a chunk we'll resume from this point. + if (isChunkFinished) + { + // If there's no way new data can arrive to complete this event then advance over it. + // If the chunk is the last one then it's considered actively writable, and so we + // presume we're seeing a torn write here. + // + // A future sync from the files on disk will delete it. + if (_sortedChunks.Count > 1) + { + _discardingHead = null; - // If there's more data in the chunk to read then loop back through - if (!isChunkFinished) continue; - - // If the chunk is finished but a newline wasn't found then refresh - // our set of chunks and loop back through - ReadChunks(); + break; + } + + // There's only a single chunk, update our state from the disk in case the writer + // has moved on to another chunk and return. We may end up coming back later and + // reading more to discard. + ReadChunks(); - ArrayPool.Shared.Return(discardingRentedArray); - batch = null; - return false; + ArrayPool.Shared.Return(discardingRentedArray); + batch = null; + return false; + } } + + ReadChunks(); + + ArrayPool.Shared.Return(discardingRentedArray); } // Fill a buffer with newline-delimited values @@ -154,45 +181,69 @@ from the underlying chunks. var batchLength = 0; BufferPosition? batchHead = null; - var chunkIndex = 0; // Try fill the buffer with as much data as possible // by walking over all chunks while (chunkIndex < _sortedChunks.Count) { var chunk = _sortedChunks[chunkIndex]; - var chunkHead = Extents(chunk); + + BufferReaderChunkExtents chunkHead; + if (chunk.Name.Id == _readHead.ChunkId) + { + // The chunk is the one we're currently reading; resume from where we left off + // If the file was truncated externally then we'll treat it as complete + chunkHead = chunk.Chunk.TryGetLength(out var length) + ? new BufferReaderChunkExtents(Math.Min(_readHead.Offset, length.Value), length.Value) + : new BufferReaderChunkExtents(_readHead.Offset, _readHead.Offset); + } + else + { + // The chunk is not the one we've been reading; start from the beginning + chunk.Chunk.TryGetLength(out var length); + chunkHead = new BufferReaderChunkExtents(0, length ?? 0); + } - if (!TryFillChunk(chunk, chunkHead, batchBuffer[batchLength..], out var fill)) + if (!TryFillChunk(chunk, chunkHead, batchBuffer[batchLength..], out var filled)) { - // If we can't read from this chunk anymore then remove it and continue - _sortedChunks.RemoveAt(chunkIndex); + // If we can't read from this chunk anymore then step over it + chunkIndex += 1; continue; } - var isBufferFull = batchLength + fill == maxSize; - var isChunkFinished = fill == chunkHead.WriteHead; + var isBufferFull = batchLength + filled == maxSize; + var isChunkFinished = chunkHead.CommitHead + filled == chunkHead.WriteHead; - // If either the buffer has been filled or we've reached the end of a chunk - // then scan to the last newline + // If either the buffer has been filled or we've reached the end of the chunk + // then scan backwards to the last newline delimiter if (isBufferFull || isChunkFinished) { - // If the chunk is finished then we expect this to immediately find a trailing newline + // If the chunk is valid and finished then we expect this to immediately find a trailing newline // NOTE: `Span.LastIndexOf` and similar methods are vectorized - var lastNewlineIndex = batchBuffer[batchLength..(batchLength + fill.Value)].LastIndexOf((byte)'\n'); + var lastNewlineIndex = batchBuffer[batchLength..(batchLength + filled.Value)].LastIndexOf((byte)'\n'); if (lastNewlineIndex == -1) { - // If this isn't the last chunk then discard the trailing data and move on + // The data we wrote didn't contain any newline delimiters + + // If there's no way new data can arrive to complete this event then advance over it. + // If the chunk is the last one then it's considered actively writable, and so we + // presume we're seeing a torn write here. + // + // A subsequent attempt to fill will overwrite the incomplete data in it, and + // a future sync from the files on disk will delete it if (isChunkFinished && chunkIndex < _sortedChunks.Count) { chunkIndex += 1; continue; } - // If this is the first chunk then we've hit an oversize payload + // If we're looking at the first chunk then start discarding + // + // We'll hit this point if we happen to start from an oversized payload, or if our last attempt + // to fill a batch advanced up to an oversize event if (chunkIndex == 0) { - _discardingHead = new BufferPosition(chunk.Name.Id, chunkHead.CommitHead + fill.Value); + _discardingHead = new BufferPosition(chunk.Name.Id, chunkHead.CommitHead + filled.Value); // Ensures we don't attempt to yield the data we've read batchHead = null; @@ -202,11 +253,12 @@ from the underlying chunks. break; } - fill = lastNewlineIndex + 1; + // Only consider the read data up to the last newline + filled = lastNewlineIndex + 1; } - batchLength += fill.Value; - batchHead = new BufferPosition(chunk.Name.Id, chunkHead.CommitHead + fill.Value); + batchLength += filled.Value; + batchHead = new BufferPosition(chunk.Name.Id, chunkHead.CommitHead + filled.Value); chunkIndex += 1; } @@ -261,17 +313,12 @@ public void AdvanceTo(BufferPosition newReaderHead) _sortedChunks.RemoveRange(0, removeLength); } - BufferReaderChunkExtents Extents(BufferReaderChunk chunk) - { - if (chunk.Name.Id == _readHead.ChunkId) - return chunk.Chunk.TryGetLength(out var writeHead) - ? new BufferReaderChunkExtents(Math.Min(_readHead.Offset, writeHead.Value), writeHead.Value) - : new BufferReaderChunkExtents(_readHead.Offset, _readHead.Offset); - - chunk.Chunk.TryGetLength(out var length); - return new BufferReaderChunkExtents(0, length ?? 0); - } - + /// + /// Read the current state of the store from files on disk. + /// + /// + /// This method will delete any files it finds before the current read head. + /// void ReadChunks() { List chunks = []; diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs b/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs index 5cc37a69..973b08da 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs @@ -12,12 +12,28 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; + namespace SeqCli.Forwarder.Storage; /// /// The current read and write positions in a . /// -readonly record struct BufferReaderChunkExtents(long CommitHead, long WriteHead) +readonly record struct BufferReaderChunkExtents { - public long Unadvanced => WriteHead - CommitHead; + public BufferReaderChunkExtents(long commitHead, long writeHead) + { + if (commitHead > writeHead) + { + throw new ArgumentOutOfRangeException(nameof(CommitHead), "The commit head cannot be greater than the write head"); + } + + CommitHead = commitHead; + WriteHead = writeHead; + } + + public long CommitHead { get; } + public long WriteHead { get; } + + public long Unadvanced => Math.Max(0, WriteHead - CommitHead); } diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs index 685e8375..4cf94086 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs @@ -20,7 +20,7 @@ public override InMemoryStoreFile Create(string name) return _files[name]; } - public InMemoryStoreFile Create(string name, Span contents) + public override InMemoryStoreFile Create(string name, Span contents) { var file = Create(name); file.Append(contents); @@ -40,16 +40,16 @@ public override bool TryDelete(string name) return _files.Remove(name); } - public override InMemoryStoreFile Replace(string toReplace, string replaceWith) + public override InMemoryStoreFile Replace(string destinationPath, string sourcePath) { - _files[toReplace] = _files[replaceWith]; + _files[destinationPath] = _files[sourcePath]; - if (!TryDelete(toReplace)) + if (!TryDelete(sourcePath)) { - throw new InvalidOperationException($"Failed to replace {toReplace} with {replaceWith}"); + throw new InvalidOperationException($"Failed to replace {destinationPath} with {sourcePath}"); } - return _files[replaceWith]; + return _files[destinationPath]; } public override IEnumerable<(string Name, StoreFile File)> List(Func predicate) diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs index e06579f9..9d44b84f 100644 --- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs +++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs @@ -19,7 +19,7 @@ public override bool TryGetLength([NotNullWhen(true)] out long? length) return true; } - public void Append(Span incoming) + public override void Append(Span incoming) { var newContents = new byte[Contents.Length + incoming.Length]; diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/TestFilesystem.cs b/test/SeqCli.Tests/Forwarder/Filesystem/TestFilesystem.cs new file mode 100644 index 00000000..26c13977 --- /dev/null +++ b/test/SeqCli.Tests/Forwarder/Filesystem/TestFilesystem.cs @@ -0,0 +1,37 @@ +using System; +using System.IO; +using SeqCli.Forwarder.Filesystem; +using SeqCli.Forwarder.Filesystem.System; + +namespace SeqCli.Tests.Forwarder.Filesystem; + +static class TestFilesystem +{ + public static void PermuteFilesystem(Action withDirectory) + { + try + { + withDirectory(new InMemoryStoreDirectory()); + } + catch (Exception e) + { + throw new Exception("In-memory permutation failed", e); + } + + var tmpDir = Path.Combine(Path.GetTempPath(), $"SeqCliTest_{Guid.NewGuid():X}"); + Directory.CreateDirectory(tmpDir); + + try + { + withDirectory(new SystemStoreDirectory(tmpDir)); + } + catch (Exception e) + { + throw new Exception("Filesystem permutation failed", e); + } + finally + { + Directory.Delete(tmpDir, true); + } + } +} diff --git a/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs b/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs index 2e94f982..9882dfb7 100644 --- a/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs +++ b/test/SeqCli.Tests/Forwarder/Storage/BookmarkTests.cs @@ -10,74 +10,78 @@ public class BookmarkTests [Fact] public void CreateSetGet() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + var bookmark = Bookmark.Open(directory); - var bookmark = Bookmark.Open(directory); + Assert.False(bookmark.TryGet(out var value)); + Assert.Null(value); - Assert.False(bookmark.TryGet(out var value)); - Assert.Null(value); + Assert.True(bookmark.TrySet(new BufferPosition(42, 1))); + Assert.True(bookmark.TryGet(out value)); + Assert.Equal(new BufferPosition(42, 1), value.Value); - Assert.True(bookmark.TrySet(new BufferPosition(42, 1))); - Assert.True(bookmark.TryGet(out value)); - Assert.Equal(new BufferPosition(42, 1), value.Value); - - Assert.True(bookmark.TrySet(new BufferPosition(42, int.MaxValue))); - Assert.True(bookmark.TryGet(out value)); - Assert.Equal(new BufferPosition(42, int.MaxValue), value.Value); + Assert.True(bookmark.TrySet(new BufferPosition(42, int.MaxValue))); + Assert.True(bookmark.TryGet(out value)); + Assert.Equal(new BufferPosition(42, int.MaxValue), value.Value); + }); } [Fact] public void OpenDeletesOldBookmarks() { - var directory = new InMemoryStoreDirectory(); - - directory.Create($"{1L:x16}.bookmark", new BufferPosition(3, 3478).Encode()); - directory.Create($"{3L:x16}.bookmark", new BufferPosition(42, 17).Encode()); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create($"{1L:x16}.bookmark", new BufferPosition(3, 3478).Encode()); + directory.Create($"{3L:x16}.bookmark", new BufferPosition(42, 17).Encode()); - Assert.Equal(2, directory.Files.Count); + Assert.Equal(2, directory.ListAll().Count()); - var bookmark = Bookmark.Open(directory); + var bookmark = Bookmark.Open(directory); - Assert.Equal($"{3L:x16}.bookmark", directory.Files.Single().Key); + Assert.Equal($"{3L:x16}.bookmark", directory.ListAll().Single().Name); - Assert.True(bookmark.TryGet(out var value)); - Assert.Equal(new BufferPosition(42, 17), value); + Assert.True(bookmark.TryGet(out var value)); + Assert.Equal(new BufferPosition(42, 17), value); + }); } [Fact] public void OpenDeletesCorruptedBookmarks() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create($"{1L:x16}.bookmark", new BufferPosition(3, 3478).Encode()); - directory.Create($"{1L:x16}.bookmark", new BufferPosition(3, 3478).Encode()); + // This bookmark is invalid + directory.Create($"{3L:x16}.bookmark", new byte[] { 1, 2, 3 }); - // This bookmark is invalid - directory.Create($"{3L:x16}.bookmark", new byte[] { 1, 2, 3 }); + var bookmark = Bookmark.Open(directory); - var bookmark = Bookmark.Open(directory); + Assert.Empty(directory.ListAll()); - Assert.Empty(directory.Files); + Assert.True(bookmark.TrySet(new BufferPosition(42, 17))); - Assert.True(bookmark.TrySet(new BufferPosition(42, 17))); - - Assert.Equal($"{4L:x16}.bookmark", directory.Files.Single().Key); + Assert.Equal($"{4L:x16}.bookmark", directory.ListAll().Single().Name); + }); } [Fact] public void OpenDeletesMisnamedBookmarks() { - var directory = new InMemoryStoreDirectory(); - - directory.Create($"{1L:x16}.bookmark", new BufferPosition(3, 3478).Encode()); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create($"{1L:x16}.bookmark", new BufferPosition(3, 3478).Encode()); - // This bookmark is invalid - directory.Create($"ff{3L:x16}.bookmark", new BufferPosition(42, 17).Encode()); + // This bookmark is invalid + directory.Create($"ff{3L:x16}.bookmark", new BufferPosition(42, 17).Encode()); - var bookmark = Bookmark.Open(directory); + var bookmark = Bookmark.Open(directory); - Assert.Single(directory.Files); + Assert.Single(directory.ListAll()); - Assert.True(bookmark.TryGet(out var value)); - Assert.Equal(new BufferPosition(3, 3478), value); + Assert.True(bookmark.TryGet(out var value)); + Assert.Equal(new BufferPosition(3, 3478), value); + }); } } diff --git a/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs b/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs index 9b91660b..60dee141 100644 --- a/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs +++ b/test/SeqCli.Tests/Forwarder/Storage/BufferTests.cs @@ -1,4 +1,5 @@ using System.Linq; +using SeqCli.Forwarder.Filesystem.System; using SeqCli.Forwarder.Storage; using SeqCli.Tests.Forwarder.Filesystem; using Xunit; @@ -10,306 +11,343 @@ public class BufferTests [Fact] public void OpenAppendRead() { - var directory = new InMemoryStoreDirectory(); - - using var writer = BufferAppender.Open(directory); - var reader = BufferReader.Open(directory); - - Assert.Empty(directory.Files); - - // Append a payload - Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); - Assert.Single(directory.Files); - - // Read the payload - Assert.False(reader.TryFillBatch(10, out _)); - Assert.True(reader.TryFillBatch(10, out var batch)); - var batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(1, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":1}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); - - // Advance the reader - reader.AdvanceTo(batchBuffer.ReaderHead); - Assert.False(reader.TryFillBatch(10, out batch)); - - // Append another payload - Assert.True(writer.TryAppend("{\"id\":2}\n"u8.ToArray(), long.MaxValue)); - Assert.Single(directory.Files); - - // Read the payload - Assert.True(reader.TryFillBatch(10, out batch)); - batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(1, 18), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); - - // Advance the reader - reader.AdvanceTo(batchBuffer.ReaderHead); - Assert.False(reader.TryFillBatch(10, out batch)); + TestFilesystem.PermuteFilesystem(directory => + { + using var writer = BufferAppender.Open(directory); + var reader = BufferReader.Open(directory); + + Assert.Empty(directory.ListAll()); + + // Append a payload + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); + Assert.Single(directory.ListAll()); + + // Read the payload + Assert.False(reader.TryFillBatch(10, out _)); + Assert.True(reader.TryFillBatch(10, out var batch)); + var batchBuffer = batch.Value; + Assert.Equal(new BufferPosition(1, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + // Advance the reader + reader.AdvanceTo(batchBuffer.ReaderHead); + Assert.False(reader.TryFillBatch(10, out batch)); + + // Append another payload + Assert.True(writer.TryAppend("{\"id\":2}\n"u8.ToArray(), long.MaxValue)); + Assert.Single(directory.ListAll()); + + // Read the payload + Assert.True(reader.TryFillBatch(10, out batch)); + batchBuffer = batch.Value; + Assert.Equal(new BufferPosition(1, 18), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + + // Advance the reader + reader.AdvanceTo(batchBuffer.ReaderHead); + Assert.False(reader.TryFillBatch(10, out batch)); + }); } [Fact] public void ReadWaitsUntilCompleteDataOnLastChunk() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + var chunk = directory.Create(new ChunkName(1).ToString(), "{\"id\":1"u8.ToArray()); - var chunk = directory.Create(new ChunkName(1).ToString(), "{\"id\":1"u8.ToArray()); + var reader = BufferReader.Open(directory); - var reader = BufferReader.Open(directory); + Assert.False(reader.TryFillBatch(512, out _)); - Assert.False(reader.TryFillBatch(512, out _)); + chunk.Append("}"u8.ToArray()); - chunk.Append("}"u8.ToArray()); + Assert.False(reader.TryFillBatch(512, out _)); - Assert.False(reader.TryFillBatch(512, out _)); + chunk.Append("\n"u8.ToArray()); - chunk.Append("\n"u8.ToArray()); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; - - Assert.Equal(new BufferPosition(1, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":1}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.Equal(new BufferPosition(1, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + }); } [Fact] public void ReadDiscardsPreviouslyReadChunks() { - var directory = new InMemoryStoreDirectory(); - - directory.Create(new ChunkName(1).ToString(), "{\"id\":1}\n"u8.ToArray()); - directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); - var reader = BufferReader.Open(directory); + var reader = BufferReader.Open(directory); - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":1}\n{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); - Assert.Equal(2, directory.Files.Count); + Assert.Equal(2, directory.ListAll().Count()); - reader.AdvanceTo(batchBuffer.ReaderHead); + reader.AdvanceTo(batchBuffer.ReaderHead); - Assert.Single(directory.Files); + Assert.Single(directory.ListAll()); - directory.Create(new ChunkName(1).ToString(), "{\"id\":1}\n"u8.ToArray()); - directory.Create(new ChunkName(3).ToString(), "{\"id\":3}\n"u8.ToArray()); + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(3).ToString(), "{\"id\":3}\n"u8.ToArray()); - Assert.Equal(3, directory.Files.Count); + Assert.Equal(3, directory.ListAll().Count()); - Assert.False(reader.TryFillBatch(512, out _)); - Assert.True(reader.TryFillBatch(512, out batch)); - batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(3, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out batch)); + batchBuffer = batch.Value; + Assert.Equal(new BufferPosition(3, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); - reader.AdvanceTo(batchBuffer.ReaderHead); + reader.AdvanceTo(batchBuffer.ReaderHead); - Assert.Single(directory.Files); + Assert.Single(directory.ListAll()); + }); } [Fact] public void AdvancingToNonexistentLowerPositionRereadsAllChunks() { - var directory = new InMemoryStoreDirectory(); - - directory.Create(new ChunkName(71).ToString(), "{\"id\":1}\n"u8.ToArray()); - directory.Create(new ChunkName(72).ToString(), "{\"id\":2}\n"u8.ToArray()); - - var reader = BufferReader.Open(directory); - - reader.AdvanceTo(new BufferPosition(60, 0)); - - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(72, 9), batchBuffer.ReaderHead); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(71).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(72).ToString(), "{\"id\":2}\n"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + reader.AdvanceTo(new BufferPosition(60, 0)); + + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + Assert.Equal(new BufferPosition(72, 9), batchBuffer.ReaderHead); + }); } [Fact] public void AdvancingToNonexistentHigherPositionDiscardsAllChunks() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(71).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(72).ToString(), "{\"id\":2}\n"u8.ToArray()); + + var reader = BufferReader.Open(directory); - directory.Create(new ChunkName(71).ToString(), "{\"id\":1}\n"u8.ToArray()); - directory.Create(new ChunkName(72).ToString(), "{\"id\":2}\n"u8.ToArray()); + reader.AdvanceTo(new BufferPosition(80, 0)); - var reader = BufferReader.Open(directory); - - reader.AdvanceTo(new BufferPosition(80, 0)); - - Assert.False(reader.TryFillBatch(512, out _)); + Assert.False(reader.TryFillBatch(512, out _)); + }); } [Fact] public void ReadDiscardsOversizePayloads() { - var directory = new InMemoryStoreDirectory(); - - using var writer = BufferAppender.Open(directory); + TestFilesystem.PermuteFilesystem(directory => + { + using var writer = BufferAppender.Open(directory); - Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); - var reader = BufferReader.Open(directory); + var reader = BufferReader.Open(directory); - Assert.False(reader.TryFillBatch(5, out _)); - Assert.False(reader.TryFillBatch(512, out _)); + Assert.False(reader.TryFillBatch(5, out _)); + Assert.False(reader.TryFillBatch(512, out _)); + }); } [Fact] public void ReadDoesNotDiscardAcrossFiles() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); - directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); - directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + var reader = BufferReader.Open(directory); - var reader = BufferReader.Open(directory); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; - - Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + }); } [Fact] public void ReadStopsDiscardingOnExternalDelete() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + + var reader = BufferReader.Open(directory); + + Assert.False(reader.TryFillBatch(5, out _)); + + // Deleting the file here will cause our discarding chunk to change + Assert.True(directory.TryDelete(new ChunkName(1).ToString())); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); - directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; + + Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + }); + } + + [Fact] + public void ReadStopsDiscardingOnExternalTruncate() + { + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); - var reader = BufferReader.Open(directory); + var reader = BufferReader.Open(directory); - Assert.False(reader.TryFillBatch(5, out _)); + Assert.False(reader.TryFillBatch(5, out _)); - // Deleting the file here will cause our discarding chunk to change - Assert.True(directory.TryDelete(new ChunkName(1).ToString())); - directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + // Changing the length of the file here will cause our discarding chunk to change + directory.ReplaceContents(new ChunkName(1).ToString(), "{}"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); - Assert.False(reader.TryFillBatch(512, out _)); - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + }); } [Fact] public void ReadStopsDiscardingOnExternalCreate() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); - directory.Create(new ChunkName(1).ToString(), "{\"id\":1}"u8.ToArray()); + var reader = BufferReader.Open(directory); - var reader = BufferReader.Open(directory); + Assert.False(reader.TryFillBatch(5, out _)); - Assert.False(reader.TryFillBatch(5, out _)); + // Creating a new file here will stop discarding + directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); - // Creating a new file here will cause a new one to be created - directory.Create(new ChunkName(2).ToString(), "{\"id\":2}\n"u8.ToArray()); + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.False(reader.TryFillBatch(512, out _)); - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; - - Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":2}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + }); } [Fact] public void AppendRolloverOnWrite() { - var directory = new InMemoryStoreDirectory(); - - using var writer = BufferAppender.Open(directory); - var reader = BufferReader.Open(directory); + TestFilesystem.PermuteFilesystem(directory => + { + using var writer = BufferAppender.Open(directory); + var reader = BufferReader.Open(directory); - Assert.Empty(directory.Files); + Assert.Empty(directory.ListAll()); - Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), 17)); - Assert.True(writer.TryAppend("{\"id\":2}\n"u8.ToArray(), 17)); + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), 17)); + Assert.True(writer.TryAppend("{\"id\":2}\n"u8.ToArray(), 17)); - Assert.Single(directory.Files); + Assert.Single(directory.ListAll()); - Assert.True(writer.TryAppend("{\"id\":3}\n"u8.ToArray(), 17)); + Assert.True(writer.TryAppend("{\"id\":3}\n"u8.ToArray(), 17)); - Assert.Equal(2, directory.Files.Count); + Assert.Equal(2, directory.ListAll().Count()); - Assert.False(reader.TryFillBatch(512, out _)); - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); - Assert.Equal("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.Equal(new BufferPosition(2, 9), batchBuffer.ReaderHead); + Assert.Equal("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); - reader.AdvanceTo(batchBuffer.ReaderHead); + reader.AdvanceTo(batchBuffer.ReaderHead); - Assert.Single(directory.Files); + Assert.Single(directory.ListAll()); + }); } [Fact] public void ExistingFilesAreReadonly() { - var directory = new InMemoryStoreDirectory(); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(0).ToString()); - directory.Create(new ChunkName(0).ToString()); + using var writer = BufferAppender.Open(directory); + var reader = BufferReader.Open(directory); - using var writer = BufferAppender.Open(directory); - var reader = BufferReader.Open(directory); + Assert.Single(directory.ListAll()); - Assert.Single(directory.Files); + Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); - Assert.True(writer.TryAppend("{\"id\":1}\n"u8.ToArray(), long.MaxValue)); + Assert.Equal(2, directory.ListAll().Count()); - Assert.Equal(2, directory.Files.Count); + Assert.False(reader.TryFillBatch(512, out _)); + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.False(reader.TryFillBatch(512, out _)); - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; - - Assert.Equal(new BufferPosition(1, 9), batchBuffer.ReaderHead); + Assert.Equal(new BufferPosition(1, 9), batchBuffer.ReaderHead); + }); } [Fact] public void OpenReadAcrossChunks() { - var directory = new InMemoryStoreDirectory(); - - directory.Create(new ChunkName(0).ToString(), "{\"id\":1}\n"u8.ToArray()); - directory.Create(new ChunkName(1).ToString(), "{\"id\":2}\n"u8.ToArray()); - directory.Create(new ChunkName(2).ToString(), "{\"id\":3}\n"u8.ToArray()); + TestFilesystem.PermuteFilesystem(directory => + { + directory.Create(new ChunkName(0).ToString(), "{\"id\":1}\n"u8.ToArray()); + directory.Create(new ChunkName(1).ToString(), "{\"id\":2}\n"u8.ToArray()); + directory.Create(new ChunkName(2).ToString(), "{\"id\":3}\n"u8.ToArray()); - var reader = BufferReader.Open(directory); + var reader = BufferReader.Open(directory); - Assert.Equal(3, directory.Files.Count); + Assert.Equal(3, directory.ListAll().Count()); - Assert.True(reader.TryFillBatch(512, out var batch)); - var batchBuffer = batch.Value; + Assert.True(reader.TryFillBatch(512, out var batch)); + var batchBuffer = batch.Value; - Assert.Equal("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); + Assert.Equal("{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"u8.ToArray(), batchBuffer.AsSpan().ToArray()); - reader.AdvanceTo(batchBuffer.ReaderHead); + reader.AdvanceTo(batchBuffer.ReaderHead); - Assert.Single(directory.Files); + Assert.Single(directory.ListAll()); + }); } [Fact] public void MaxChunksOnAppender() { - var directory = new InMemoryStoreDirectory(); - - using var appender = BufferAppender.Open(directory); + TestFilesystem.PermuteFilesystem(directory => + { + using var appender = BufferAppender.Open(directory); - for (var i = 0; i < 10; i++) Assert.True(appender.TryAppend("{\"id\":1}\n"u8.ToArray(), 5, 3)); + for (var i = 0; i < 10; i++) Assert.True(appender.TryAppend("{\"id\":1}\n"u8.ToArray(), 5, 3)); - var files = directory.Files.Select(f => f.Key).ToList(); - files.Sort(); + var files = directory.ListAll().Select(f => f.Name).ToList(); + files.Sort(); - Assert.Equal([ - "0000000000000008.clef", - "0000000000000009.clef", - "000000000000000a.clef" - ], files); + Assert.Equal([ + "0000000000000008.clef", + "0000000000000009.clef", + "000000000000000a.clef" + ], files); + }); } } From a92f0af7235c11678786775e9e65a6a3a684d312 Mon Sep 17 00:00:00 2001 From: Ashley Mannix Date: Mon, 30 Mar 2026 15:49:34 +1000 Subject: [PATCH 20/98] dispose files before trying to delete them --- src/SeqCli/Forwarder/Storage/BufferReader.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SeqCli/Forwarder/Storage/BufferReader.cs b/src/SeqCli/Forwarder/Storage/BufferReader.cs index 14d605b9..e14b8213 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReader.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReader.cs @@ -297,6 +297,7 @@ public void AdvanceTo(BufferPosition newReaderHead) // The remainder of the chunk is being skipped if (chunk.Name.Id < newReaderHead.ChunkId) { + chunk.Dispose(); _storeDirectory.TryDelete(chunk.Name.ToString()); } else From d2bc708d308db056b454a38d330075aed131bfed Mon Sep 17 00:00:00 2001 From: KodrAus Date: Tue, 31 Mar 2026 09:47:02 +1000 Subject: [PATCH 21/98] update chunk file name --- src/SeqCli/Forwarder/Storage/BufferReader.cs | 6 +++--- src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs | 8 ++++---- src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs | 7 +++++++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/SeqCli/Forwarder/Storage/BufferReader.cs b/src/SeqCli/Forwarder/Storage/BufferReader.cs index e14b8213..8c902905 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReader.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReader.cs @@ -99,7 +99,7 @@ from the underlying chunks. // Try read to the end of the chunk // // If reading the chunk length fails then advance over it - if (!chunk.Chunk.TryGetLength(out var length)) + if (!chunk.File.TryGetLength(out var length)) { chunkIndex += 1; @@ -193,14 +193,14 @@ from the underlying chunks. { // The chunk is the one we're currently reading; resume from where we left off // If the file was truncated externally then we'll treat it as complete - chunkHead = chunk.Chunk.TryGetLength(out var length) + chunkHead = chunk.File.TryGetLength(out var length) ? new BufferReaderChunkExtents(Math.Min(_readHead.Offset, length.Value), length.Value) : new BufferReaderChunkExtents(_readHead.Offset, _readHead.Offset); } else { // The chunk is not the one we've been reading; start from the beginning - chunk.Chunk.TryGetLength(out var length); + chunk.File.TryGetLength(out var length); chunkHead = new BufferReaderChunkExtents(0, length ?? 0); } diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs b/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs index d85cb20e..d7cf5168 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReaderChunk.cs @@ -22,14 +22,14 @@ namespace SeqCli.Forwarder.Storage; /// class BufferReaderChunk : IDisposable { - public BufferReaderChunk(ChunkName name, StoreFile chunk) + public BufferReaderChunk(ChunkName name, StoreFile file) { Name = name; - Chunk = chunk; + File = file; } public ChunkName Name { get; } - public StoreFile Chunk { get; } + public StoreFile File { get; } (long, StoreFileReader)? _reader; @@ -53,7 +53,7 @@ public bool TryCopyTo(Span buffer, BufferReaderChunkExtents chunkExtents, if (_reader == null) { - if (!Chunk.TryOpenRead(chunkExtents.WriteHead, out var reader)) return false; + if (!File.TryOpenRead(chunkExtents.WriteHead, out var reader)) return false; _reader = (chunkExtents.WriteHead, reader); } diff --git a/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs b/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs index 973b08da..5a101b2a 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs @@ -32,7 +32,14 @@ public BufferReaderChunkExtents(long commitHead, long writeHead) WriteHead = writeHead; } + /// + /// The inclusive start of the range. + /// public long CommitHead { get; } + + /// + /// The exclusive end of the range. + /// public long WriteHead { get; } public long Unadvanced => Math.Max(0, WriteHead - CommitHead); From d02abf50c3fc0f94f9692fd7665d2280b4a67aac Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 31 Mar 2026 10:46:54 +1000 Subject: [PATCH 22/98] Santize trace and span ids to avoid deserialization errors when hosting plug-in apps --- src/SeqCli/Apps/Hosting/AppContainer.cs | 34 +++++++++++++++- test/SeqCli.Tests/Apps/AppContainerTests.cs | 43 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/src/SeqCli/Apps/Hosting/AppContainer.cs b/src/SeqCli/Apps/Hosting/AppContainer.cs index 90e05667..4b233f11 100644 --- a/src/SeqCli/Apps/Hosting/AppContainer.cs +++ b/src/SeqCli/Apps/Hosting/AppContainer.cs @@ -15,6 +15,7 @@ using System; using System.Globalization; using System.IO; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -29,11 +30,13 @@ namespace SeqCli.Apps.Hosting; -class AppContainer : IAppHost, IDisposable +partial class AppContainer : IAppHost, IDisposable { readonly SeqApp _seqApp; readonly AppLoader _loader; + static readonly Regex HexDigits = HexDigitsRegex(); + readonly JsonSerializer _serializer = JsonSerializer.Create(new JsonSerializerSettings { DateParseHandling = DateParseHandling.None, @@ -143,6 +146,8 @@ LogEvent ReadSerilogEvent(string clef, out string eventId, out uint eventType) jobject.Add("@l", new JValue(LevelMapping.ToSerilogLevel(levelToken.Value()!).ToString())); } + SanitizeTraceIdentifiers(jobject); + var raw = LogEventReader.ReadFromJObject(jobject); eventId = "event-0"; @@ -158,6 +163,29 @@ LogEvent ReadSerilogEvent(string clef, out string eventId, out uint eventType) return raw; } + internal static void SanitizeTraceIdentifiers(JObject jobject) + { + // Serilog.Formatting.Compact.Reader constructs LogEvents which use the System.Diagnostics ActivityTraceId + // and ActivitySpanId types; these throw when constructed with invalid inputs. + + if (jobject.TryGetValue("@tr", out var traceIdToken) && !IsValidHexIdentifier(traceIdToken, 32)) + jobject.Remove("@tr"); + + if (jobject.TryGetValue("@sp", out var spanIdToken) && !IsValidHexIdentifier(spanIdToken, 16)) + jobject.Remove("@sp"); + + if (jobject.TryGetValue("@ps", out var parentSpanIdToken) && !IsValidHexIdentifier(parentSpanIdToken, 16)) + jobject.Remove("@ps"); + } + + static bool IsValidHexIdentifier(JToken? value, int requiredChars) + { + if (value?.Value() is not { } id) + return false; + + return id.Length == requiredChars && HexDigits.IsMatch(id); + } + public void StartPublishing(TextWriter inputWriter) { if (_seqApp is IPublishJson pjson) @@ -169,4 +197,8 @@ public void StopPublishing() if (_seqApp is IPublishJson pjson) pjson.Stop(); } + + // Technically, + [GeneratedRegex("^[0-9a-f]*$")] + private static partial Regex HexDigitsRegex(); } \ No newline at end of file diff --git a/test/SeqCli.Tests/Apps/AppContainerTests.cs b/test/SeqCli.Tests/Apps/AppContainerTests.cs index e769a801..c3168845 100644 --- a/test/SeqCli.Tests/Apps/AppContainerTests.cs +++ b/test/SeqCli.Tests/Apps/AppContainerTests.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.IO; +using Newtonsoft.Json.Linq; using Seq.Apps; using SeqCli.Apps.Hosting; using SeqCli.Tests.Support; @@ -37,4 +38,46 @@ public void CanLoadInputApp() appContainer.Dispose(); } + + [Fact] + public void RemovesInvalidTraceAndSpanIds() + { + var jo = new JObject + { + ["@tr"] = "A234567890123456A234567890123456", + ["@sp"] = "A234567890123456", + ["@ps"] = "A234567890123456" + }; + Assert.NotEmpty(jo); + AppContainer.SanitizeTraceIdentifiers(jo); + Assert.Empty(jo); + } + + [Fact] + public void RemovesShortTraceAndSpanIds() + { + var jo = new JObject + { + ["@tr"] = "234567890123456234567890123456", + ["@sp"] = "234567890123456", + ["@ps"] = "234567890123456" + }; + Assert.NotEmpty(jo); + AppContainer.SanitizeTraceIdentifiers(jo); + Assert.Empty(jo); + } + + [Fact] + public void PreservesValidTraceAndSpanIds() + { + var jo = new JObject + { + ["@tr"] = "a234567890123456a234567890123456", + ["@sp"] = "a234567890123456", + ["@ps"] = "a234567890123456" + }; + Assert.Equal(3, jo.Count); + AppContainer.SanitizeTraceIdentifiers(jo); + Assert.Equal(3, jo.Count); + } } \ No newline at end of file From 973f8687fd7bd19190f8d8cdd16f9e7829a3ec68 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 31 Mar 2026 11:49:33 +1000 Subject: [PATCH 23/98] Allow CI workflows to be initiated manually --- .github/workflows/ci.yml | 9 ++++++++- build/Build.Windows.ps1 | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfbd102e..df7c596a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,18 @@ on: branches: [ "dev", "main" ] pull_request: branches: [ "dev", "main" ] + workflow_dispatch: + inputs: + publish: + description: 'Publish a GitHub release' + type: boolean + required: true + default: true env: CI_BUILD_NUMBER_BASE: ${{ github.run_number }} CI_TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} - CI_PUBLISH: ${{ github.event_name == 'push' && github.ref_name == 'main' }} + CI_PUBLISH: ${{ (github.event_name == 'push' && github.ref_name == 'main') || inputs.publish }} jobs: build-windows: diff --git a/build/Build.Windows.ps1 b/build/Build.Windows.ps1 index 6fd0ff3e..f2df699c 100644 --- a/build/Build.Windows.ps1 +++ b/build/Build.Windows.ps1 @@ -111,8 +111,13 @@ function Upload-NugetPackages function Upload-GitHubRelease($version) { Write-Output "Creating release for version $version" + + $prerelease = ""; + if ($env:CI_TARGET_BRANCH -ne "main") { + $prerelease = "--prerelease "; + } - iex "gh release create v$version --title v$version --generate-notes $(get-item ./artifacts/*)" + iex "gh release create v$version --title v$version $prerelease--generate-notes $(get-item ./artifacts/*)" } function Remove-GlobalJson From 98039e988d14f20ff9745a42431c0e5916014f4e Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 31 Mar 2026 13:32:49 +1000 Subject: [PATCH 24/98] Update baseversion to 2026.1; `dev` now uses the .NET SDK matching Seq 2026.1, so no further 2025.2 releases will be made from it --- baseversion | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/baseversion b/baseversion index 89c24c57..6033ed8b 100644 --- a/baseversion +++ b/baseversion @@ -1 +1 @@ -2025.2 \ No newline at end of file +2026.1 From 82fd409ab53bb61faaf44cd705e36491e44d1e85 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 9 Apr 2026 09:31:37 +1000 Subject: [PATCH 25/98] Detect and drop all-zero trace/span/parent ids --- src/SeqCli/Apps/Hosting/AppContainer.cs | 12 ++++++------ test/SeqCli.Tests/Apps/AppContainerTests.cs | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/SeqCli/Apps/Hosting/AppContainer.cs b/src/SeqCli/Apps/Hosting/AppContainer.cs index 4b233f11..2f52c589 100644 --- a/src/SeqCli/Apps/Hosting/AppContainer.cs +++ b/src/SeqCli/Apps/Hosting/AppContainer.cs @@ -15,6 +15,7 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Newtonsoft.Json; @@ -35,7 +36,7 @@ partial class AppContainer : IAppHost, IDisposable readonly SeqApp _seqApp; readonly AppLoader _loader; - static readonly Regex HexDigits = HexDigitsRegex(); + static readonly Regex NonZeroHex = NonZeroHexRegex(); readonly JsonSerializer _serializer = JsonSerializer.Create(new JsonSerializerSettings { @@ -183,7 +184,7 @@ static bool IsValidHexIdentifier(JToken? value, int requiredChars) if (value?.Value() is not { } id) return false; - return id.Length == requiredChars && HexDigits.IsMatch(id); + return id.Length == requiredChars && NonZeroHex.IsMatch(id); } public void StartPublishing(TextWriter inputWriter) @@ -198,7 +199,6 @@ public void StopPublishing() pjson.Stop(); } - // Technically, - [GeneratedRegex("^[0-9a-f]*$")] - private static partial Regex HexDigitsRegex(); -} \ No newline at end of file + [GeneratedRegex("^0*[1-9a-f][0-9a-f]*$")] + private static partial Regex NonZeroHexRegex(); +} diff --git a/test/SeqCli.Tests/Apps/AppContainerTests.cs b/test/SeqCli.Tests/Apps/AppContainerTests.cs index c3168845..e9cfdecc 100644 --- a/test/SeqCli.Tests/Apps/AppContainerTests.cs +++ b/test/SeqCli.Tests/Apps/AppContainerTests.cs @@ -67,6 +67,20 @@ public void RemovesShortTraceAndSpanIds() Assert.Empty(jo); } + [Fact] + public void RemovesZeroTraceAndSpanIds() + { + var jo = new JObject + { + ["@tr"] = "00000000000000000000000000000000", + ["@sp"] = "0000000000000000", + ["@ps"] = "0000000000000000" + }; + Assert.NotEmpty(jo); + AppContainer.SanitizeTraceIdentifiers(jo); + Assert.Empty(jo); + } + [Fact] public void PreservesValidTraceAndSpanIds() { From 61006d39e3184f8c0ff1fc84e1dfefc8c300df9a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 7 May 2026 16:19:54 +1000 Subject: [PATCH 26/98] Dependency/SDK updates; waiting on a Seq.Api 2026.1 build --- ci.global.json | 2 +- src/Roastery/Roastery.csproj | 2 +- src/SeqCli/SeqCli.csproj | 16 ++++++++-------- test/SeqCli.Tests/SeqCli.Tests.csproj | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/ci.global.json b/ci.global.json index ce67766b..d20023b3 100644 --- a/ci.global.json +++ b/ci.global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "10.0.201" + "version": "10.0.203" } } diff --git a/src/Roastery/Roastery.csproj b/src/Roastery/Roastery.csproj index 8b6a076c..3a9f8487 100644 --- a/src/Roastery/Roastery.csproj +++ b/src/Roastery/Roastery.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 6d99f8a8..ce5d3ea0 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -32,21 +32,21 @@ - - + + - - - - - + + + + + - + diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj index 41b33c8d..e1bc6937 100644 --- a/test/SeqCli.Tests/SeqCli.Tests.csproj +++ b/test/SeqCli.Tests/SeqCli.Tests.csproj @@ -3,7 +3,7 @@ net10.0 - + all From e478234fe4369335e3bb4380a2b5e95bf332483a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 8 May 2026 08:46:10 +1000 Subject: [PATCH 27/98] Include `seqcli view *` commands and other metrics updates --- .../Cli/Commands/Dashboard/RenderCommand.cs | 2 +- .../Cli/Commands/Forwarder/InstallCommand.cs | 1 - .../Commands/RetentionPolicy/CreateCommand.cs | 62 ++++++++-- src/SeqCli/Cli/Commands/View/CreateCommand.cs | 112 ++++++++++++++++++ src/SeqCli/Cli/Commands/View/ListCommand.cs | 56 +++++++++ src/SeqCli/Cli/Commands/View/RemoveCommand.cs | 70 +++++++++++ src/SeqCli/Cli/Commands/View/UpdateCommand.cs | 24 ++++ .../Cli/Commands/Workspace/CreateCommand.cs | 3 +- src/SeqCli/Syntax/QueryBuilder.cs | 10 +- 9 files changed, 319 insertions(+), 21 deletions(-) create mode 100644 src/SeqCli/Cli/Commands/View/CreateCommand.cs create mode 100644 src/SeqCli/Cli/Commands/View/ListCommand.cs create mode 100644 src/SeqCli/Cli/Commands/View/RemoveCommand.cs create mode 100644 src/SeqCli/Cli/Commands/View/UpdateCommand.cs diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index 4d78f61b..be8657c7 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -181,7 +181,7 @@ static string BuildSqlQuery(ChartQueryPart query, DateTime rangeStart, DateTime foreach (var measurement in query.Measurements) sql.Select(measurement.Value, measurement.Label); - sql.FromStream = true; + sql.From = query.DataSource.ToString().ToLowerInvariant(); sql.Where($"@Timestamp >= DateTime('{rangeStart:O}')"); sql.Where($"@Timestamp < DateTime('{rangeEnd:O}')"); diff --git a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs index fadb3a80..e08fb32c 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs @@ -15,7 +15,6 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Threading.Tasks; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs index 5947752d..8fdbb409 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs @@ -14,6 +14,7 @@ using System; using System.Threading.Tasks; +using Seq.Api.Model.Shared; using Seq.Api.Model.Signals; using SeqCli.Api; using SeqCli.Cli.Features; @@ -26,7 +27,7 @@ namespace SeqCli.Cli.Commands.RetentionPolicy; [Command("retention", "create", "Create a retention policy", - Example = "seqcli retention create --after 30d --delete-all-events")] + Example = "seqcli retention create --after 30d --data-source stream --delete-all")] class CreateCommand : Command { readonly ConnectionFeature _connection; @@ -34,7 +35,8 @@ class CreateCommand : Command readonly StoragePathFeature _storagePath; string? _afterDuration; - bool _deleteAllEvents; + bool _deleteAll; + string? _dataSource; string? _deleteMatchingSignal; public CreateCommand() @@ -43,21 +45,36 @@ public CreateCommand() "after=", "A duration after which the policy will delete events, e.g. `7d`", v => _afterDuration = ArgumentString.Normalize(v)); - + Options.Add( - "delete-all-events", - "The policy should delete all events (currently the only supported option)", - _ => _deleteAllEvents = true); + "data-source=", + "The data source to delete records from (`stream` for log events and traces, or `series` for metrics); defaults to `stream`", + v => _dataSource = ArgumentString.Normalize(v)); + + Options.Add( + "delete-all", + "The policy should delete all records in the target data source after the specified duration", + _ => _deleteAll = true); Options.Add( "delete=", - "Stream incoming events to this app instance as they're ingested; optionally accepts a signal expression limiting which events should be streamed", + "A signal expression identifying events that should be deleted; not supported by the `series` data source", s => { _deleteMatchingSignal = s; } ); - + + // Maintained for compatibility in Seq 2026.x; intended to be retired in 2027. + Options.Add( + "delete-all-events", + "The policy should delete all events from `stream`", + _ => + { + _deleteAll = true; + _dataSource = null; + }, hidden: true); + _connection = Enable(); _output = Enable(); _storagePath = Enable(); @@ -71,11 +88,11 @@ protected override async Task Run() SignalExpressionPart? removedSignalExpression; // Exactly one of `delete-all-events` or `delete` must be specified - if (_deleteAllEvents) + if (_deleteAll) { if (!string.IsNullOrEmpty(_deleteMatchingSignal)) { - Log.Error("Only one of the `delete-all-events` or `delete` options may be specified"); + Log.Error("Only one of the `--delete-all` or `--delete` options may be specified"); return 1; } @@ -83,7 +100,7 @@ protected override async Task Run() } else if (string.IsNullOrEmpty(_deleteMatchingSignal)) { - Log.Error("Either the `delete-all-events` or `delete` options must be specified"); + Log.Error("One of either the `--delete-all` or `--delete` options must be specified"); return 1; } else @@ -93,15 +110,34 @@ protected override async Task Run() if (_afterDuration == null) { - Log.Error("A duration must be specified using `after`"); + Log.Error("A duration must be specified using `--after`"); return 1; } var duration = DurationMoniker.ToTimeSpan(_afterDuration); - + + if (_dataSource == null) + { + Log.Error("Use `--data-source` to specify `stream` or `series` as the retention target"); + return 1; + } + + if (!Enum.TryParse(_dataSource, out DataSource dataSource)) + { + Log.Error("The `--data-source` option supports `stream` and `series`"); + return 1; + } + + if (removedSignalExpression != null && dataSource != DataSource.Stream) + { + Log.Error("The `--delete` option is only valid when `--data-source` is `stream`"); + return 1; + } + var policy = await connection.RetentionPolicies.TemplateAsync(); policy.RetentionTime = duration; policy.RemovedSignalExpression = removedSignalExpression; + policy.DataSource = dataSource; policy = await connection.RetentionPolicies.AddAsync(policy); diff --git a/src/SeqCli/Cli/Commands/View/CreateCommand.cs b/src/SeqCli/Cli/Commands/View/CreateCommand.cs new file mode 100644 index 00000000..95830f79 --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/CreateCommand.cs @@ -0,0 +1,112 @@ +// Copyright 2018 Datalust Pty Ltd 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 System.Threading.Tasks; +using Seq.Api.Model.Shared; +using Seq.Api.Model.Signals; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.View; + +[Command("view", "create", "Create a metrics view", + Example = "seqcli view create -t 'API' -g '@Scope.name' -f \"@Resource.service.name = 'seq_cafe_web'\"")] +class CreateCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; + + string? _title, _description, _filter; + readonly List _groups = []; + bool _isProtected; + + public CreateCommand() + { + Options.Add( + "t=|title=", + "A title for the view", + t => _title = ArgumentString.Normalize(t)); + + Options.Add( + "description=", + "A description for the view", + d => _description = ArgumentString.Normalize(d)); + + Options.Add( + "f=|filter=", + "Expression filter to associate with the view", + f => _filter = ArgumentString.Normalize(f)); + + Options.Add( + "g=|group=", + "Group key expression to associate with the view; this argument can be used multiple times", + c => _groups.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Group keys require a value."))); + + Options.Add( + "protected", + "Specify that the view is editable only by administrators", + _ => _isProtected = true); + + _connection = Enable(); + _output = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + if (string.IsNullOrEmpty(_title)) + { + Log.Error("A title must be specified"); + return 1; + } + + var view = await connection.Views.TemplateAsync(); + view.OwnerId = null; + + view.Title = _title; + view.Description = _description; + view.IsProtected = _isProtected; + + if (_filter != null) + { + view.Filters = new[] + { + new DescriptiveFilterPart + { + Filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression, + FilterNonStrict = _filter + } + }.ToList(); + } + + foreach (var group in _groups) + view.GroupKeyExpressions.Add(group); + + view = await connection.Views.AddAsync(view); + + _output.GetOutputFormat(config).WriteEntity(view); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/View/ListCommand.cs b/src/SeqCli/Cli/Commands/View/ListCommand.cs new file mode 100644 index 00000000..a890a5a8 --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/ListCommand.cs @@ -0,0 +1,56 @@ +// Copyright 2018 Datalust Pty Ltd 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.Linq; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; + +namespace SeqCli.Cli.Commands.View; + +[Command("view", "list", "List available metrics views", Example="seqcli view list")] +class ListCommand : Command +{ + readonly EntityIdentityFeature _entityIdentity; + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly EntityOwnerFeature _entityOwner; + readonly StoragePathFeature _storagePath; + + public ListCommand() + { + _entityIdentity = Enable(new EntityIdentityFeature("view", "list")); + _entityOwner = Enable(new EntityOwnerFeature("view", "list", "listed", _entityIdentity)); + _output = Enable(); + _storagePath = Enable(); + _connection = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var list = _entityIdentity.Id != null ? [await connection.Views.FindAsync(_entityIdentity.Id)] + : + (await connection.Views.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(signal => _entityIdentity.Title == null || _entityIdentity.Title == signal.Title); + + _output.GetOutputFormat(config).ListEntities(list); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/View/RemoveCommand.cs b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs new file mode 100644 index 00000000..d8375dff --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs @@ -0,0 +1,70 @@ +// Copyright 2018 Datalust Pty Ltd 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.Linq; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + +namespace SeqCli.Cli.Commands.View; + +[Command("view", "remove", "Remove a metrics view from the server", + Example = "seqcli view remove -t 'Test View'")] +class RemoveCommand : Command +{ + readonly EntityIdentityFeature _entityIdentity; + readonly ConnectionFeature _connection; + readonly EntityOwnerFeature _entityOwner; + readonly StoragePathFeature _storagePath; + + public RemoveCommand() + { + _entityIdentity = Enable(new EntityIdentityFeature("view", "remove")); + _entityOwner = Enable(new EntityOwnerFeature("view", "remove", "removed", _entityIdentity)); + _connection = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + if (_entityIdentity.Title == null && _entityIdentity.Id == null) + { + Log.Error("A `title` or `id` must be specified"); + return 1; + } + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var toRemove = _entityIdentity.Id != null ? [await connection.Views.FindAsync(_entityIdentity.Id)] + : + (await connection.Views.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(signal => _entityIdentity.Title == signal.Title) + .ToArray(); + + if (!toRemove.Any()) + { + Log.Error("No matching view was found"); + return 1; + } + + foreach (var signal in toRemove) + await connection.Views.RemoveAsync(signal); + + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/View/UpdateCommand.cs b/src/SeqCli/Cli/Commands/View/UpdateCommand.cs new file mode 100644 index 00000000..a1a20b6b --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/UpdateCommand.cs @@ -0,0 +1,24 @@ +// Copyright © Datalust Pty Ltd 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 Seq.Api; + +namespace SeqCli.Cli.Commands.View; + +[Command("view", "update", + "Update an existing metrics view", + Example="seqcli view update --json '{...}'")] +class UpdateCommand(): + Shared.UpdateCommand("view", nameof(SeqConnection.Views)); + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs b/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs index cf4999d1..1554708e 100644 --- a/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs @@ -37,7 +37,7 @@ public CreateCommand() Options.Add( "c=|content=", - "The id of a dashboard, signal, or saved query to include in the workspace", + "The id of a dashboard, signal, saved query, or view to include in the workspace", d => _include.Add(ArgumentString.Normalize(d))); Options.Add( @@ -65,6 +65,7 @@ protected override async Task Run() workspace.Content.DashboardIds.AddRange(_include.Where(s => s?.StartsWith("dashboard-") ?? false)); workspace.Content.QueryIds.AddRange(_include.Where(s => s?.StartsWith("sqlquery-") ?? false)); workspace.Content.SignalIds.AddRange(_include.Where(s => s?.StartsWith("signal-") ?? false)); + workspace.Content.ViewIds.AddRange(_include.Where(s => s?.StartsWith("view-") ?? false)); workspace = await connection.Workspaces.AddAsync(workspace); diff --git a/src/SeqCli/Syntax/QueryBuilder.cs b/src/SeqCli/Syntax/QueryBuilder.cs index b2338ec4..0eaba8a1 100644 --- a/src/SeqCli/Syntax/QueryBuilder.cs +++ b/src/SeqCli/Syntax/QueryBuilder.cs @@ -21,18 +21,18 @@ namespace SeqCli.Syntax; class QueryBuilder { - readonly List<(string, string)> _columns = new(); + readonly List<(string, string?)> _columns = new(); readonly List _where = new(); readonly List _groupBy = new(); readonly List _having = new(); - public void Select(string value, string label) + public void Select(string value, string? label) { if (value == null) throw new ArgumentNullException(nameof(value)); _columns.Add((value, label)); } - public bool FromStream { get; set; } + public string? From { get; set; } public void Where(string predicate) { @@ -74,8 +74,8 @@ public string Build() result.Append($" as {label}"); } - if (FromStream) - result.Append(" from stream"); + if (From is {} from) + result.Append($" from {from}"); if (_where.Any()) { From 2b4366479e1b2deb9ef176c27bb9100d3c4373ed Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 8 May 2026 08:55:12 +1000 Subject: [PATCH 28/98] Update inconsistent copyright headers --- src/SeqCli/Api/ApiConstants.cs | 2 +- src/SeqCli/Api/SeqConnectionFactory.cs | 2 +- src/SeqCli/Apps/AppLoader.cs | 2 +- src/SeqCli/Apps/Definitions/AppDefinition.cs | 2 +- src/SeqCli/Apps/Definitions/AppDefinitionFormatter.cs | 2 +- src/SeqCli/Apps/Definitions/AppMetadataReader.cs | 2 +- src/SeqCli/Apps/Definitions/AppPlatformDefinition.cs | 2 +- src/SeqCli/Apps/Definitions/AppSettingDefinition.cs | 2 +- src/SeqCli/Apps/Definitions/AppSettingType.cs | 2 +- src/SeqCli/Apps/Definitions/AppSettingValue.cs | 2 +- src/SeqCli/Apps/Hosting/AppActivator.cs | 2 +- src/SeqCli/Apps/Hosting/AppHost.cs | 2 +- src/SeqCli/Apps/Hosting/EventFormat.cs | 2 +- src/SeqCli/Cli/Command.cs | 2 +- src/SeqCli/Cli/CommandAttribute.cs | 2 +- src/SeqCli/Cli/CommandFeature.cs | 2 +- src/SeqCli/Cli/CommandLineHost.cs | 2 +- src/SeqCli/Cli/CommandMetadata.cs | 2 +- src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs | 2 +- src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Commands/App/DefineCommand.cs | 2 +- src/SeqCli/Cli/Commands/App/RunCommand.cs | 2 +- src/SeqCli/Cli/Commands/Config/LegacyCommand.cs | 2 +- src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs | 2 +- src/SeqCli/Cli/Commands/Events/DeleteCommand.cs | 2 +- src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Commands/Feed/CreateCommand.cs | 2 +- src/SeqCli/Cli/Commands/Feed/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Commands/HelpCommand.cs | 2 +- src/SeqCli/Cli/Commands/Index/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/Index/SuppressCommand.cs | 2 +- src/SeqCli/Cli/Commands/IngestCommand.cs | 2 +- src/SeqCli/Cli/Commands/LogCommand.cs | 2 +- src/SeqCli/Cli/Commands/PrintCommand.cs | 2 +- src/SeqCli/Cli/Commands/QueryCommand.cs | 2 +- src/SeqCli/Cli/Commands/SearchCommand.cs | 2 +- src/SeqCli/Cli/Commands/Signal/CreateCommand.cs | 2 +- src/SeqCli/Cli/Commands/Signal/ImportCommand.cs | 2 +- src/SeqCli/Cli/Commands/Signal/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Commands/TailCommand.cs | 2 +- src/SeqCli/Cli/Commands/User/CreateCommand.cs | 2 +- src/SeqCli/Cli/Commands/User/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/User/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Commands/VersionCommand.cs | 2 +- src/SeqCli/Cli/Commands/View/CreateCommand.cs | 2 +- src/SeqCli/Cli/Commands/View/ListCommand.cs | 2 +- src/SeqCli/Cli/Commands/View/RemoveCommand.cs | 2 +- src/SeqCli/Cli/Features/ConnectionFeature.cs | 2 +- src/SeqCli/Cli/Features/DateRangeFeature.cs | 2 +- src/SeqCli/Cli/Features/EntityIdentityFeature.cs | 2 +- src/SeqCli/Cli/Features/EntityOwnerFeature.cs | 2 +- src/SeqCli/Cli/Features/FileInputFeature.cs | 2 +- src/SeqCli/Cli/Features/InvalidDataHandlingFeature.cs | 2 +- src/SeqCli/Cli/Features/PropertiesFeature.cs | 2 +- src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs | 2 +- src/SeqCli/Cli/Features/SettingNameFeature.cs | 2 +- src/SeqCli/Cli/Features/SignalExpressionFeature.cs | 2 +- src/SeqCli/Cli/Features/UserIdentityFeature.cs | 2 +- src/SeqCli/Cli/ICommandMetadata.cs | 2 +- src/SeqCli/Cli/Printing.cs | 2 +- src/SeqCli/Config/SeqCliConfig.cs | 2 +- src/SeqCli/Config/SeqCliConnectionConfig.cs | 2 +- src/SeqCli/Config/SeqCliOutputConfig.cs | 2 +- .../Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs | 2 +- src/SeqCli/Ingestion/InvalidDataHandling.cs | 2 +- src/SeqCli/Ingestion/JsonLogEventReader.cs | 2 +- src/SeqCli/Ingestion/LogShipper.cs | 2 +- src/SeqCli/Ingestion/ScalarPropertyEnricher.cs | 2 +- src/SeqCli/Ingestion/SendFailureHandling.cs | 2 +- src/SeqCli/Mapping/LevelMapping.cs | 2 +- src/SeqCli/Output/RedundantEventTypeRemovalEnricher.cs | 2 +- src/SeqCli/PlainText/Framing/Frame.cs | 2 +- src/SeqCli/PlainText/Framing/FrameReader.cs | 2 +- src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs | 2 +- src/SeqCli/PlainText/LogEvents/TextOnlyException.cs | 2 +- src/SeqCli/Program.cs | 2 +- src/SeqCli/SeqCliModule.cs | 2 +- src/SeqCli/Signals/SignalExpressionParser.cs | 2 +- src/SeqCli/Signals/SignalExpressionToken.cs | 2 +- src/SeqCli/Signals/SignalExpressionTokenizer.cs | 2 +- src/SeqCli/Syntax/CombinedFilterBuilder.cs | 2 +- src/SeqCli/Syntax/DurationMoniker.cs | 2 +- src/SeqCli/Syntax/QueryBuilder.cs | 2 +- src/SeqCli/Util/ArgumentString.cs | 2 +- src/SeqCli/Util/DirectoryExt.cs | 2 +- src/SeqCli/Util/Presentation.cs | 2 +- src/SeqCli/Util/UserScopeDataProtection.cs | 2 +- 92 files changed, 92 insertions(+), 92 deletions(-) diff --git a/src/SeqCli/Api/ApiConstants.cs b/src/SeqCli/Api/ApiConstants.cs index 58e5576b..138382dd 100644 --- a/src/SeqCli/Api/ApiConstants.cs +++ b/src/SeqCli/Api/ApiConstants.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Api/SeqConnectionFactory.cs b/src/SeqCli/Api/SeqConnectionFactory.cs index 76c4bf18..1f4c5859 100644 --- a/src/SeqCli/Api/SeqConnectionFactory.cs +++ b/src/SeqCli/Api/SeqConnectionFactory.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Apps/AppLoader.cs b/src/SeqCli/Apps/AppLoader.cs index 90872ef2..c0a03ff5 100644 --- a/src/SeqCli/Apps/AppLoader.cs +++ b/src/SeqCli/Apps/AppLoader.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppDefinition.cs b/src/SeqCli/Apps/Definitions/AppDefinition.cs index 5c9e1773..2d8a6488 100644 --- a/src/SeqCli/Apps/Definitions/AppDefinition.cs +++ b/src/SeqCli/Apps/Definitions/AppDefinition.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppDefinitionFormatter.cs b/src/SeqCli/Apps/Definitions/AppDefinitionFormatter.cs index c8c1223a..4b9741dc 100644 --- a/src/SeqCli/Apps/Definitions/AppDefinitionFormatter.cs +++ b/src/SeqCli/Apps/Definitions/AppDefinitionFormatter.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppMetadataReader.cs b/src/SeqCli/Apps/Definitions/AppMetadataReader.cs index 58269661..2bab3789 100644 --- a/src/SeqCli/Apps/Definitions/AppMetadataReader.cs +++ b/src/SeqCli/Apps/Definitions/AppMetadataReader.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppPlatformDefinition.cs b/src/SeqCli/Apps/Definitions/AppPlatformDefinition.cs index 9ded07b5..15bcd293 100644 --- a/src/SeqCli/Apps/Definitions/AppPlatformDefinition.cs +++ b/src/SeqCli/Apps/Definitions/AppPlatformDefinition.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppSettingDefinition.cs b/src/SeqCli/Apps/Definitions/AppSettingDefinition.cs index 00ebeb8e..5fdbb82b 100644 --- a/src/SeqCli/Apps/Definitions/AppSettingDefinition.cs +++ b/src/SeqCli/Apps/Definitions/AppSettingDefinition.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppSettingType.cs b/src/SeqCli/Apps/Definitions/AppSettingType.cs index c2e9175a..e749b641 100644 --- a/src/SeqCli/Apps/Definitions/AppSettingType.cs +++ b/src/SeqCli/Apps/Definitions/AppSettingType.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Definitions/AppSettingValue.cs b/src/SeqCli/Apps/Definitions/AppSettingValue.cs index 246a640a..037d4b1d 100644 --- a/src/SeqCli/Apps/Definitions/AppSettingValue.cs +++ b/src/SeqCli/Apps/Definitions/AppSettingValue.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Hosting/AppActivator.cs b/src/SeqCli/Apps/Hosting/AppActivator.cs index 7f084174..6c747de6 100644 --- a/src/SeqCli/Apps/Hosting/AppActivator.cs +++ b/src/SeqCli/Apps/Hosting/AppActivator.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Hosting/AppHost.cs b/src/SeqCli/Apps/Hosting/AppHost.cs index 09a5c62e..3f48e118 100644 --- a/src/SeqCli/Apps/Hosting/AppHost.cs +++ b/src/SeqCli/Apps/Hosting/AppHost.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Apps/Hosting/EventFormat.cs b/src/SeqCli/Apps/Hosting/EventFormat.cs index f9bd960e..1ff23253 100644 --- a/src/SeqCli/Apps/Hosting/EventFormat.cs +++ b/src/SeqCli/Apps/Hosting/EventFormat.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Command.cs b/src/SeqCli/Cli/Command.cs index b0ed94df..4b366b49 100644 --- a/src/SeqCli/Cli/Command.cs +++ b/src/SeqCli/Cli/Command.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/CommandAttribute.cs b/src/SeqCli/Cli/CommandAttribute.cs index 60facce0..332fcd08 100644 --- a/src/SeqCli/Cli/CommandAttribute.cs +++ b/src/SeqCli/Cli/CommandAttribute.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/CommandFeature.cs b/src/SeqCli/Cli/CommandFeature.cs index 80997d58..2be4024f 100644 --- a/src/SeqCli/Cli/CommandFeature.cs +++ b/src/SeqCli/Cli/CommandFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/CommandLineHost.cs b/src/SeqCli/Cli/CommandLineHost.cs index d48eb561..8b1298fe 100644 --- a/src/SeqCli/Cli/CommandLineHost.cs +++ b/src/SeqCli/Cli/CommandLineHost.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/CommandMetadata.cs b/src/SeqCli/Cli/CommandMetadata.cs index cc7bf027..ae631bf7 100644 --- a/src/SeqCli/Cli/CommandMetadata.cs +++ b/src/SeqCli/Cli/CommandMetadata.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs index 5ebbfb08..fe514434 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/CreateCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs index 3e2a9dbf..4cd44f5c 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs index b3789c91..ac5900b8 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/App/DefineCommand.cs b/src/SeqCli/Cli/Commands/App/DefineCommand.cs index 56909bbb..40e2a54f 100644 --- a/src/SeqCli/Cli/Commands/App/DefineCommand.cs +++ b/src/SeqCli/Cli/Commands/App/DefineCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/App/RunCommand.cs b/src/SeqCli/Cli/Commands/App/RunCommand.cs index a3590f46..be6542c8 100644 --- a/src/SeqCli/Cli/Commands/App/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/App/RunCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Config/LegacyCommand.cs b/src/SeqCli/Cli/Commands/Config/LegacyCommand.cs index a23b46bc..5deb9852 100644 --- a/src/SeqCli/Cli/Commands/Config/LegacyCommand.cs +++ b/src/SeqCli/Cli/Commands/Config/LegacyCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018-2021 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs index 89dc94c1..7d9cc7a2 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs index c16dc0a9..28c0845c 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index be8657c7..80ed95e9 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs b/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs index 2e9aa26e..8ae6927b 100644 --- a/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs +++ b/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2026 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs index 38784e73..d660404b 100644 --- a/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs b/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs index 2a4543ff..306e4dc0 100644 --- a/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/CreateCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Feed/ListCommand.cs b/src/SeqCli/Cli/Commands/Feed/ListCommand.cs index d4b15670..f203018f 100644 --- a/src/SeqCli/Cli/Commands/Feed/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs index 4710d24f..898b55f0 100644 --- a/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/HelpCommand.cs b/src/SeqCli/Cli/Commands/HelpCommand.cs index fe502899..f926e341 100644 --- a/src/SeqCli/Cli/Commands/HelpCommand.cs +++ b/src/SeqCli/Cli/Commands/HelpCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/Index/ListCommand.cs b/src/SeqCli/Cli/Commands/Index/ListCommand.cs index 780e510b..9452f36d 100644 --- a/src/SeqCli/Cli/Commands/Index/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Index/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs index 27517e29..da742ed1 100644 --- a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs +++ b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/IngestCommand.cs b/src/SeqCli/Cli/Commands/IngestCommand.cs index 3eb2f822..b965ddb3 100644 --- a/src/SeqCli/Cli/Commands/IngestCommand.cs +++ b/src/SeqCli/Cli/Commands/IngestCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/LogCommand.cs b/src/SeqCli/Cli/Commands/LogCommand.cs index a360ba32..6873f9e6 100644 --- a/src/SeqCli/Cli/Commands/LogCommand.cs +++ b/src/SeqCli/Cli/Commands/LogCommand.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/PrintCommand.cs b/src/SeqCli/Cli/Commands/PrintCommand.cs index 51d845ab..4eca68ec 100644 --- a/src/SeqCli/Cli/Commands/PrintCommand.cs +++ b/src/SeqCli/Cli/Commands/PrintCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 95b63481..17381bea 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 41a7539b..2d096195 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs b/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs index 414d124a..43ef8187 100644 --- a/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/CreateCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs b/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs index d810c8b5..2e0c0741 100644 --- a/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/ImportCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Signal/ListCommand.cs b/src/SeqCli/Cli/Commands/Signal/ListCommand.cs index 24932b36..883d1448 100644 --- a/src/SeqCli/Cli/Commands/Signal/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs index 75a5a397..06f65a60 100644 --- a/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 26510b84..75fa33b4 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/User/CreateCommand.cs b/src/SeqCli/Cli/Commands/User/CreateCommand.cs index e670b9f7..c2adc9c0 100644 --- a/src/SeqCli/Cli/Commands/User/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/User/CreateCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/User/ListCommand.cs b/src/SeqCli/Cli/Commands/User/ListCommand.cs index 52965047..62ae3979 100644 --- a/src/SeqCli/Cli/Commands/User/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/User/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/User/RemoveCommand.cs b/src/SeqCli/Cli/Commands/User/RemoveCommand.cs index 8616fafa..c855c2ad 100644 --- a/src/SeqCli/Cli/Commands/User/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/User/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/VersionCommand.cs b/src/SeqCli/Cli/Commands/VersionCommand.cs index 411d7523..69050da1 100644 --- a/src/SeqCli/Cli/Commands/VersionCommand.cs +++ b/src/SeqCli/Cli/Commands/VersionCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Commands/View/CreateCommand.cs b/src/SeqCli/Cli/Commands/View/CreateCommand.cs index 95830f79..dfecae50 100644 --- a/src/SeqCli/Cli/Commands/View/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/View/CreateCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/View/ListCommand.cs b/src/SeqCli/Cli/Commands/View/ListCommand.cs index a890a5a8..5acc0f0a 100644 --- a/src/SeqCli/Cli/Commands/View/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/View/ListCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Commands/View/RemoveCommand.cs b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs index d8375dff..b5569e11 100644 --- a/src/SeqCli/Cli/Commands/View/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Cli/Features/ConnectionFeature.cs b/src/SeqCli/Cli/Features/ConnectionFeature.cs index e832e8c9..11c2c59c 100644 --- a/src/SeqCli/Cli/Features/ConnectionFeature.cs +++ b/src/SeqCli/Cli/Features/ConnectionFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/DateRangeFeature.cs b/src/SeqCli/Cli/Features/DateRangeFeature.cs index b88f60cf..509afc50 100644 --- a/src/SeqCli/Cli/Features/DateRangeFeature.cs +++ b/src/SeqCli/Cli/Features/DateRangeFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/EntityIdentityFeature.cs b/src/SeqCli/Cli/Features/EntityIdentityFeature.cs index a6f9dab2..57aceee2 100644 --- a/src/SeqCli/Cli/Features/EntityIdentityFeature.cs +++ b/src/SeqCli/Cli/Features/EntityIdentityFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/EntityOwnerFeature.cs b/src/SeqCli/Cli/Features/EntityOwnerFeature.cs index ae0f5188..d2f56bc6 100644 --- a/src/SeqCli/Cli/Features/EntityOwnerFeature.cs +++ b/src/SeqCli/Cli/Features/EntityOwnerFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/FileInputFeature.cs b/src/SeqCli/Cli/Features/FileInputFeature.cs index a38110f2..3c4ed0a5 100644 --- a/src/SeqCli/Cli/Features/FileInputFeature.cs +++ b/src/SeqCli/Cli/Features/FileInputFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/InvalidDataHandlingFeature.cs b/src/SeqCli/Cli/Features/InvalidDataHandlingFeature.cs index af7c308d..910d0aa2 100644 --- a/src/SeqCli/Cli/Features/InvalidDataHandlingFeature.cs +++ b/src/SeqCli/Cli/Features/InvalidDataHandlingFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/PropertiesFeature.cs b/src/SeqCli/Cli/Features/PropertiesFeature.cs index 3000952a..fd0dde48 100644 --- a/src/SeqCli/Cli/Features/PropertiesFeature.cs +++ b/src/SeqCli/Cli/Features/PropertiesFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs b/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs index 97892e73..19f6fe10 100644 --- a/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs +++ b/src/SeqCli/Cli/Features/SendFailureHandlingFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/SettingNameFeature.cs b/src/SeqCli/Cli/Features/SettingNameFeature.cs index c089c2a6..025ac25a 100644 --- a/src/SeqCli/Cli/Features/SettingNameFeature.cs +++ b/src/SeqCli/Cli/Features/SettingNameFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/SignalExpressionFeature.cs b/src/SeqCli/Cli/Features/SignalExpressionFeature.cs index bc190249..54522c15 100644 --- a/src/SeqCli/Cli/Features/SignalExpressionFeature.cs +++ b/src/SeqCli/Cli/Features/SignalExpressionFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Features/UserIdentityFeature.cs b/src/SeqCli/Cli/Features/UserIdentityFeature.cs index 8a9cc748..c9976f3d 100644 --- a/src/SeqCli/Cli/Features/UserIdentityFeature.cs +++ b/src/SeqCli/Cli/Features/UserIdentityFeature.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/ICommandMetadata.cs b/src/SeqCli/Cli/ICommandMetadata.cs index 89143c23..55a0bb21 100644 --- a/src/SeqCli/Cli/ICommandMetadata.cs +++ b/src/SeqCli/Cli/ICommandMetadata.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Cli/Printing.cs b/src/SeqCli/Cli/Printing.cs index 2784f16c..96b726b4 100644 --- a/src/SeqCli/Cli/Printing.cs +++ b/src/SeqCli/Cli/Printing.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Config/SeqCliConfig.cs b/src/SeqCli/Config/SeqCliConfig.cs index 4a0bec4f..c28f9f89 100644 --- a/src/SeqCli/Config/SeqCliConfig.cs +++ b/src/SeqCli/Config/SeqCliConfig.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Config/SeqCliConnectionConfig.cs b/src/SeqCli/Config/SeqCliConnectionConfig.cs index e5af87ca..4fe093cf 100644 --- a/src/SeqCli/Config/SeqCliConnectionConfig.cs +++ b/src/SeqCli/Config/SeqCliConnectionConfig.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Config/SeqCliOutputConfig.cs b/src/SeqCli/Config/SeqCliOutputConfig.cs index 18529c90..9fa2626d 100644 --- a/src/SeqCli/Config/SeqCliOutputConfig.cs +++ b/src/SeqCli/Config/SeqCliOutputConfig.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs index 6b83458f..b07a25ac 100644 --- a/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs +++ b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs @@ -1,4 +1,4 @@ -// Copyright 2020 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Ingestion/InvalidDataHandling.cs b/src/SeqCli/Ingestion/InvalidDataHandling.cs index 0e88b914..facceb01 100644 --- a/src/SeqCli/Ingestion/InvalidDataHandling.cs +++ b/src/SeqCli/Ingestion/InvalidDataHandling.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Ingestion/JsonLogEventReader.cs b/src/SeqCli/Ingestion/JsonLogEventReader.cs index 7d5d9d90..719da10c 100644 --- a/src/SeqCli/Ingestion/JsonLogEventReader.cs +++ b/src/SeqCli/Ingestion/JsonLogEventReader.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index 337e5008..97060913 100644 --- a/src/SeqCli/Ingestion/LogShipper.cs +++ b/src/SeqCli/Ingestion/LogShipper.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Ingestion/ScalarPropertyEnricher.cs b/src/SeqCli/Ingestion/ScalarPropertyEnricher.cs index 7846358e..7146c7e7 100644 --- a/src/SeqCli/Ingestion/ScalarPropertyEnricher.cs +++ b/src/SeqCli/Ingestion/ScalarPropertyEnricher.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Ingestion/SendFailureHandling.cs b/src/SeqCli/Ingestion/SendFailureHandling.cs index ae9cb221..91a38833 100644 --- a/src/SeqCli/Ingestion/SendFailureHandling.cs +++ b/src/SeqCli/Ingestion/SendFailureHandling.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Mapping/LevelMapping.cs b/src/SeqCli/Mapping/LevelMapping.cs index 99d16aa8..ff79087b 100644 --- a/src/SeqCli/Mapping/LevelMapping.cs +++ b/src/SeqCli/Mapping/LevelMapping.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Output/RedundantEventTypeRemovalEnricher.cs b/src/SeqCli/Output/RedundantEventTypeRemovalEnricher.cs index d422af32..d32e6666 100644 --- a/src/SeqCli/Output/RedundantEventTypeRemovalEnricher.cs +++ b/src/SeqCli/Output/RedundantEventTypeRemovalEnricher.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/PlainText/Framing/Frame.cs b/src/SeqCli/PlainText/Framing/Frame.cs index 7c0dbf44..0c81c7e7 100644 --- a/src/SeqCli/PlainText/Framing/Frame.cs +++ b/src/SeqCli/PlainText/Framing/Frame.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/PlainText/Framing/FrameReader.cs b/src/SeqCli/PlainText/Framing/FrameReader.cs index 3488b3ec..b117b9e1 100644 --- a/src/SeqCli/PlainText/Framing/FrameReader.cs +++ b/src/SeqCli/PlainText/Framing/FrameReader.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs index bab68619..adf1f87d 100644 --- a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs +++ b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/PlainText/LogEvents/TextOnlyException.cs b/src/SeqCli/PlainText/LogEvents/TextOnlyException.cs index 35024ea7..614c927f 100644 --- a/src/SeqCli/PlainText/LogEvents/TextOnlyException.cs +++ b/src/SeqCli/PlainText/LogEvents/TextOnlyException.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index c6e12e12..f58599da 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -1,4 +1,4 @@ -// Copyright 2018-2019 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/SeqCliModule.cs b/src/SeqCli/SeqCliModule.cs index 2cfc7fb7..0c1cb00e 100644 --- a/src/SeqCli/SeqCliModule.cs +++ b/src/SeqCli/SeqCliModule.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Signals/SignalExpressionParser.cs b/src/SeqCli/Signals/SignalExpressionParser.cs index b4970bd0..ed3e12db 100644 --- a/src/SeqCli/Signals/SignalExpressionParser.cs +++ b/src/SeqCli/Signals/SignalExpressionParser.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Signals/SignalExpressionToken.cs b/src/SeqCli/Signals/SignalExpressionToken.cs index 6b4becfb..1968aef1 100644 --- a/src/SeqCli/Signals/SignalExpressionToken.cs +++ b/src/SeqCli/Signals/SignalExpressionToken.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Signals/SignalExpressionTokenizer.cs b/src/SeqCli/Signals/SignalExpressionTokenizer.cs index 2e667529..853677e4 100644 --- a/src/SeqCli/Signals/SignalExpressionTokenizer.cs +++ b/src/SeqCli/Signals/SignalExpressionTokenizer.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Syntax/CombinedFilterBuilder.cs b/src/SeqCli/Syntax/CombinedFilterBuilder.cs index 90753295..21de240d 100644 --- a/src/SeqCli/Syntax/CombinedFilterBuilder.cs +++ b/src/SeqCli/Syntax/CombinedFilterBuilder.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Syntax/DurationMoniker.cs b/src/SeqCli/Syntax/DurationMoniker.cs index c02a2398..a579ff8d 100644 --- a/src/SeqCli/Syntax/DurationMoniker.cs +++ b/src/SeqCli/Syntax/DurationMoniker.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Syntax/QueryBuilder.cs b/src/SeqCli/Syntax/QueryBuilder.cs index 0eaba8a1..41e3459f 100644 --- a/src/SeqCli/Syntax/QueryBuilder.cs +++ b/src/SeqCli/Syntax/QueryBuilder.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Util/ArgumentString.cs b/src/SeqCli/Util/ArgumentString.cs index 90c539e8..4544d395 100644 --- a/src/SeqCli/Util/ArgumentString.cs +++ b/src/SeqCli/Util/ArgumentString.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd +// 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. diff --git a/src/SeqCli/Util/DirectoryExt.cs b/src/SeqCli/Util/DirectoryExt.cs index 0d67992b..36728957 100644 --- a/src/SeqCli/Util/DirectoryExt.cs +++ b/src/SeqCli/Util/DirectoryExt.cs @@ -1,4 +1,4 @@ -// Copyright 2019 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Util/Presentation.cs b/src/SeqCli/Util/Presentation.cs index b6e498d2..125ee15b 100644 --- a/src/SeqCli/Util/Presentation.cs +++ b/src/SeqCli/Util/Presentation.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. diff --git a/src/SeqCli/Util/UserScopeDataProtection.cs b/src/SeqCli/Util/UserScopeDataProtection.cs index 481019fe..3b8afb33 100644 --- a/src/SeqCli/Util/UserScopeDataProtection.cs +++ b/src/SeqCli/Util/UserScopeDataProtection.cs @@ -1,4 +1,4 @@ -// Copyright 2018 Datalust Pty Ltd and Contributors +// 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. From 8727d750e9fcc99dab91a6e852718aa149ae239c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 8 May 2026 09:07:22 +1000 Subject: [PATCH 29/98] Update retention policy test case to use 2025.2-compatible features; set minimum API version on others that need a newer Seq target (we may improve compatibility with older Seq versions prior to a full 2026.1 client release) --- test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs | 1 + .../RetentionPolicy/RetentionPolicyBasicsTestCase.cs | 6 +++--- .../Templates/TemplateExportImportTestCase.cs | 2 +- test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs b/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs index 4c29aff9..e7baad65 100644 --- a/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs +++ b/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs @@ -7,6 +7,7 @@ namespace SeqCli.EndToEnd.Dashboard; +[CliTestCase(MinimumApiVersion = "2026.1.0")] public class RenderTestCase : ICliTestCase { public Task ExecuteAsync( diff --git a/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs b/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs index 2a64a0e8..7116cf9e 100644 --- a/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/RetentionPolicy/RetentionPolicyBasicsTestCase.cs @@ -18,7 +18,7 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm exit = runner.Exec("retention list", "-i retentionpolicy-missing"); Assert.Equal(1, exit); - exit = runner.Exec("retention create", "--after 10h --delete-all-events"); + exit = runner.Exec("retention create", "--after 10h --data-source stream --delete-all"); Assert.Equal(0, exit); var id = runner.LastRunProcess.Output.Trim(); @@ -36,13 +36,13 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm Assert.Equal(1, exit); var deleteSignal = "signal-m33303,(signal-m33301~signal-m33302)"; - exit = runner.Exec("retention create", $"--after 10h --delete \"{deleteSignal}\""); + exit = runner.Exec("retention create", $"--after 10h --data-source stream --delete \"{deleteSignal}\""); Assert.Equal(0, exit); exit = runner.Exec("retention create", "--after 10h"); Assert.Equal(1, exit); - exit = runner.Exec("retention create", $"--after 10h --delete-all-events --delete \"{deleteSignal}\""); + exit = runner.Exec("retention create", $"--after 10h --data-source stream --delete-all --delete \"{deleteSignal}\""); Assert.Equal(1, exit); } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs b/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs index 1b5dc311..aa751ad1 100644 --- a/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs +++ b/test/SeqCli.EndToEnd/Templates/TemplateExportImportTestCase.cs @@ -8,7 +8,7 @@ namespace SeqCli.EndToEnd.Templates; -[CliTestCase(MinimumApiVersion = "2024.3.0")] +[CliTestCase(MinimumApiVersion = "2026.1.0")] public class TemplateExportImportTestCase : ICliTestCase { readonly TestDataFolder _testDataFolder; diff --git a/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs b/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs index e76c2f2c..114466cc 100644 --- a/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs @@ -7,6 +7,7 @@ namespace SeqCli.EndToEnd.Workspace; +[CliTestCase(MinimumApiVersion = "2026.1.0")] class WorkspaceBasicsTestCase : ICliTestCase { public async Task ExecuteAsync( From 63ad1951eb43bb71e4a9faf2b8c868bc32d0c31c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 8 May 2026 09:59:35 +1000 Subject: [PATCH 30/98] Misssing ignoreCase: true --- src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs index 8fdbb409..4c9c5bdb 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs @@ -122,7 +122,7 @@ protected override async Task Run() return 1; } - if (!Enum.TryParse(_dataSource, out DataSource dataSource)) + if (!Enum.TryParse(_dataSource, ignoreCase: true, out DataSource dataSource)) { Log.Error("The `--data-source` option supports `stream` and `series`"); return 1; From 00d8d0f657a5a088f4437b4f9a6408433fedbbbd Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 8 May 2026 10:53:50 +1000 Subject: [PATCH 31/98] Update Actions dependencies to latest --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df7c596a..9f642330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,9 +29,9 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - name: Build and Publish @@ -45,15 +45,15 @@ jobs: build-linux: name: Build (Linux) - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: dotnet-version: 10.0.x - name: Configure Docker From 4d4940ce55b9815d44dc0fd7924f037b6f450c05 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 26 May 2026 12:55:33 +1000 Subject: [PATCH 32/98] Mark SeqCli.EndToEnd as a non-test project so that it's not picked up automatically by dotnet test --- test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj index 00eec61a..33dd29e3 100644 --- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj +++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj @@ -3,6 +3,8 @@ Exe net10.0 + false + false From 184952b26f32e3e55d34d9069e6e8de89d296914 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 26 May 2026 13:59:18 +1000 Subject: [PATCH 33/98] seqcli skills install command --- .../Cli/Commands/Skills/InstallCommand.cs | 56 +++++++++++++++++++ src/SeqCli/SeqCli.csproj | 3 + .../Resources/seq-query-grammar/SKILL.md | 0 src/SeqCli/Skills/SkillInstaller.cs | 52 +++++++++++++++++ .../Skills/SkillsInstallTestCase.cs | 31 ++++++++++ 5 files changed, 142 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Skills/InstallCommand.cs create mode 100644 src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md create mode 100644 src/SeqCli/Skills/SkillInstaller.cs create mode 100644 test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs new file mode 100644 index 00000000..9be88fc1 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -0,0 +1,56 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO; +using System.Threading.Tasks; +using SeqCli.Skills; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.Skills; + +[Command("skills", "install", "Install or update Seq agent skills", + Example = "seqcli skills install --global --agent claude")] +class InstallCommand : Command +{ + bool _global; + string? _agent; + + public InstallCommand() + { + Options.Add( + "g|global", + "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills.`", + _ => _global = true); + + Options.Add( + "a=|agent=", + "The agent name to install skills for; the default is the generic name `agents`.", + t => _agent = ArgumentString.Normalize(t)); + } + + protected override Task Run() + { + var skillsPath = Path.Combine( + _global ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Environment.CurrentDirectory, + $".{_agent?.ToLowerInvariant() ?? "agents"}", + "skills"); + + Log.Information("Installing skills to {SkillsPath}", skillsPath); + SkillInstaller.Install(skillsPath); + + return Task.FromResult(0); + } +} \ No newline at end of file diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index ce5d3ea0..df304e8c 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -29,6 +29,9 @@ PreserveNewest + + PreserveNewest + diff --git a/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md b/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md new file mode 100644 index 00000000..e69de29b diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs new file mode 100644 index 00000000..90736448 --- /dev/null +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -0,0 +1,52 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.IO; +using Serilog; + +namespace SeqCli.Skills; + +static class SkillInstaller +{ + public static void Install(string destinationPath) + { + var sourcePath = Path.Combine(AppContext.BaseDirectory, "Skills"); + + foreach (var skillSourceDirectory in Directory.EnumerateDirectories(sourcePath)) + { + var skillName = Path.GetFileName(skillSourceDirectory); + var destination = Path.Combine(destinationPath, skillName); + + Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath); + + CopyFilesRecursive(skillSourceDirectory, destination); + } + } + + static void CopyFilesRecursive(string source, string destination) + { + Directory.CreateDirectory(destination); + + foreach (var file in Directory.EnumerateFiles(source)) + { + File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + } + + foreach (var directory in Directory.EnumerateDirectories(source)) + { + CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory))); + } + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs new file mode 100644 index 00000000..7f3a9623 --- /dev/null +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Skills; + +public class SkillsInstallTestCase : ICliTestCase +{ + public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + using var tmp = new TestDataFolder(); + var previous = Environment.CurrentDirectory; + Environment.CurrentDirectory = tmp.Path; + try + { + var exit = runner.Exec("skills install -a test-agent"); + Assert.Equal(0, exit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-query-grammar/SKILL.md"))); + } + finally + { + Environment.CurrentDirectory = previous; + } + + return Task.CompletedTask; + } +} \ No newline at end of file From b3a406225d0324f9ab70ef5f5348446e0749ffb0 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 11:08:52 +1000 Subject: [PATCH 34/98] More skill WIP --- .claude/skills/seq-search-and-query/SKILL.md | 8 + src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 59 +++ src/SeqCli/Cli/Commands/SearchCommand.cs | 6 +- src/SeqCli/Ingestion/LogShipper.cs | 2 +- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 191 ++++++++ src/SeqCli/Mcp/McpSession.cs | 76 ++++ src/SeqCli/Mcp/Tools/Search/SearchTool.cs | 258 +++++++++++ .../PlainText/LogEvents/LogEventBuilder.cs | 2 +- src/SeqCli/SeqCli.csproj | 4 + .../Resources/seq-query-grammar/SKILL.md | 0 .../Resources/seq-search-and-query/SKILL.md | 416 ++++++++++++++++++ .../Skills/SkillsInstallTestCase.cs | 2 +- 12 files changed, 1018 insertions(+), 6 deletions(-) create mode 100644 .claude/skills/seq-search-and-query/SKILL.md create mode 100644 src/SeqCli/Cli/Commands/Mcp/RunCommand.cs create mode 100644 src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs create mode 100644 src/SeqCli/Mcp/McpSession.cs create mode 100644 src/SeqCli/Mcp/Tools/Search/SearchTool.cs delete mode 100644 src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md create mode 100644 src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md new file mode 100644 index 00000000..f6467791 --- /dev/null +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -0,0 +1,8 @@ +--- +name: seq-search-and-query +description: Search and query logs and spans in Seq. Use when interacting with Seq. +license: Apache-2.0 +metadata: + author: Datalust and Contributors +--- + diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs new file mode 100644 index 00000000..5bcb900b --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -0,0 +1,59 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading.Tasks; +using Autofac.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Mcp; +using SeqCli.Mcp.Tools.Search; +using Serilog; + +namespace SeqCli.Cli.Commands.Mcp; + +[Command("mcp", "run", "Run an MCP (Model Context Protocol) server on STDIO")] +class RunCommand: Command +{ + readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; + + public RunCommand() + { + _connection = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + + var builder = Host.CreateApplicationBuilder(); + builder.ConfigureContainer(new AutofacServiceProviderFactory()); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); + builder.Services.AddSingleton(); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools([ + typeof(SearchTool) + ]); + + await builder.Build().RunAsync(); + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index 2d096195..cb8166b8 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -128,7 +128,7 @@ protected override async Task Run() } } - LogEvent ToSerilogEvent(EventEntity evt) + internal static LogEvent ToSerilogEvent(EventEntity evt) { return new LogEvent( DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(), @@ -149,12 +149,12 @@ static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart toke return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}"); } - LogEventProperty CreateProperty(string name, object value) + static LogEventProperty CreateProperty(string name, object value) { return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); } - LogEventPropertyValue CreatePropertyValue(object value) + static LogEventPropertyValue CreatePropertyValue(object value) { switch (value) { diff --git a/src/SeqCli/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index 97060913..c5738604 100644 --- a/src/SeqCli/Ingestion/LogShipper.cs +++ b/src/SeqCli/Ingestion/LogShipper.cs @@ -181,7 +181,7 @@ static async Task ReadBatchAsync( if (isLast || batch.Count != 0 || totalWaitMS > maxWaitMS) break; - // Nothing to to ship; wait to try to fill a batch. + // Nothing to ship; wait to try to fill a batch. await Task.Delay(idleWaitMS); totalWaitMS += idleWaitMS; continue; diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs new file mode 100644 index 00000000..a1ee6147 --- /dev/null +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; +using SeqCli.Syntax; + +namespace SeqCli.Mcp.Formatting; + +// Constructs Seq syntax literals from API events. This provides a language model client with strong cues as +// to how the properties of an event should be incorporated into future queries/expressions. +static partial class SeqSyntaxFormatter +{ + static readonly object UndefinedValue = new(); + + [GeneratedRegex("[_a-zA-Z][_a-zA-Z0-9]*")] + private static partial Regex IdentifierRegex(); + + public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) + { + WriteObject( + output, + true, + ("@Id", evt.Id), + ("@Timestamp", DateTimeOffset.Parse(evt.Timestamp).UtcDateTime), + ("@Level", evt.Level ?? "Information"), + ("@Message", evt.RenderedMessage), + ("@MessageTemplate", ReconstructTemplate(evt.MessageTemplateTokens)), + ("@EventType", ParseEventType(evt.EventType)), + ("@Exception", evt.Exception ?? UndefinedValue), + ("@Elapsed", evt.Elapsed ?? UndefinedValue), + ("@TraceId", evt.TraceId ?? UndefinedValue), + ("@SpanId", evt.SpanId ?? UndefinedValue), + ("@SpanKind", evt.SpanKind ?? UndefinedValue), + ("@Start", evt.Start != null ? DateTimeOffset.Parse(evt.Start).UtcDateTime : UndefinedValue), + ("@ParentId", evt.ParentId ?? UndefinedValue), + ("@Properties", evt.Properties?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Properties)) : UndefinedValue), + ("@Scope", evt.Scope?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Scope)) : UndefinedValue), + ("@Resource", evt.Resource?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Resource)) : UndefinedValue), + ("@Definitions", evt.Definitions?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Definitions)) : UndefinedValue) + ); + } + + static uint ParseEventType(string dollarPrefixedHex) + { + return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); + } + + static string ReconstructTemplate(IEnumerable tokens) + { + return string.Concat(tokens.Select(t => t.RawText)); + } + + static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) + { + output.Write('{'); + var first = true; + foreach (var (name, value) in members) + { + if (value == UndefinedValue) + continue; + + if (first) + first = false; + else + output.Write(", "); + + if (topLevel) + { + output.Write(name); + } + else + { + WriteMemberName(output, name); + } + + output.Write(": "); + + if (value is Action valueWriter) + { + valueWriter(output); + } + else + { + WriteValue(output, value); + } + } + output.Write('}'); + } + + static void WriteValue(TextWriter output, object? value) + { + if (value == UndefinedValue) + { + // This should never occur, but works in case it becomes necessary. + output.Write("@Undefined"); + return; + } + + switch (value) + { + case null: + output.Write("null"); + return; + case true: + output.Write("true"); + return; + case false: + output.Write("false"); + return; + } + + if (value is string s) + { + output.Write('\''); + output.Write(s.Replace("'", "''")); + output.Write('\''); + return; + } + + if (value is decimal + or double or float or Half + or byte or ushort or uint or ulong or UInt128 or + sbyte or short or int or long or Int128) + { + output.Write(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + return; + } + + if (value is TimeSpan ts) + { + output.Write(DurationMoniker.FromTimeSpan(ts)); + return; + } + + if (value is DateTime dt) + { + output.Write($"DateTime('{dt:O}')"); + } + + if (value is JArray ja) + { + var first = false; + output.Write('['); + foreach (var element in ja) + { + if (first) + first = false; + else + output.Write(", "); + WriteValue(output, element); + } + output.Write(']'); + return; + } + + if (value is JObject jo) + { + WriteObject(output, false, jo.Properties().Select(p => (p.Name, (object?)p.Value))); + } + + if (value is JValue jt) + { + WriteValue(output, jt.Value); + return; + } + + WriteValue(output, value.ToString()); + } + + static void WriteMemberName(TextWriter output, string name) + { + if (IdentifierRegex().IsMatch(name)) + { + output.Write(name); + } + else + { + WriteValue(output, name); + } + } + + static void WritePropertiesObject(TextWriter output, List members) + { + WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); + } +} diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs new file mode 100644 index 00000000..61df7c13 --- /dev/null +++ b/src/SeqCli/Mcp/McpSession.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; +using Seq.Api.Model.Events; +using System; + +namespace SeqCli.Mcp; + +class McpSession +{ + readonly Lock _sync = new(); + int _nextId = 1; + readonly Dictionary _resultIdToEventId = new(); + readonly Dictionary _eventIdToResult = new(); + + public string ImportSearchResult(EventEntity evt) + { + lock (_sync) + { + if (_eventIdToResult.TryGetValue(evt.Id, out var existing)) + return FormatResultId(existing.Item1); + var resultId = _nextId; + _nextId += 1; + _resultIdToEventId.Add(resultId, evt.Id); + _eventIdToResult.Add(evt.Id, (resultId, evt)); + return FormatResultId(resultId); + } + } + + static string FormatResultId(int resultId) + { + return "E" + resultId.ToString("X5"); + } + + static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) + { + if (!formatted.StartsWith('E') || !int.TryParse(formatted.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + { + resultId = null; + return false; + } + + resultId = parsed; + return true; + } + + public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, [NotNullWhen(false)] out string? error) + { + if (!TryParseResultId(resultId, out var parsed)) + { + result = null; + error = + "The result id is not correctly formatted; result ids are strings beginning with `E`, followed by a short character string."; + return false; + } + + lock (_sync) + { + if (!_resultIdToEventId.TryGetValue(parsed.Value, out var eventId)) + { + result = null; + error = + "A matching result wasn't found among recent searches. Try retrieving a fresh result id by searching again (using a very narrow time range if possible)."; + return false; + } + + if (!_eventIdToResult.TryGetValue(eventId, out var pair)) + throw new InvalidOperationException("Missing result mapping."); + + result = pair.Item2; + error = null; + return true; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs new file mode 100644 index 00000000..0080f6db --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Seq.Api; +using Seq.Api.Model.Events; +using Seq.Api.Model.Expressions; +using Seq.Syntax.Templates; +using SeqCli.Cli.Commands; +using SeqCli.Mapping; +using SeqCli.Mcp.Formatting; +using Serilog; +using Serilog.Events; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Search; + +[McpServerToolType] +class SearchTool(McpSession session, SeqConnection connection) +{ + const string ResultIdPropertyName = "__seqcli_ResultId"; + static readonly ExpressionTemplate SearchResultFormatter = new ( + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{Substring(ToString(@x), 0, 140)}}" + ); + + [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] + [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + + "a `result_id` of the form `E..` which is valid in the current MCP session. Individual events can be " + + "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + + "skill when calling this tool.")] + [return: Description("Search results and status information.")] + public async Task SearchEvents( + [Description("The maximum number of events to return.")] + [Range(1, 1000)] + int limit, + [Description("A Seq search expression evaluated over event properties.")] + string? predicate = null, + [Description("The search timeout, in seconds; the default is 45.")] + [Range(5, 180)] + int timeoutSeconds = 45, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(predicate)) + { + if (!predicate.Contains("@Timestamp") || predicate.Contains("@Id")) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = "The predicate doesn't adequately constrain the search range (by `@Timestamp` or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", + } + ] + }; + } + + ExpressionPart strict; + try + { + strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); + } + catch (Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = "The Seq API client failed while attempting to validate the search expression." + }, + new TextContentBlock + { + Text = ex.ToString() + } + ], + }; + } + if (strict.MatchedAsText) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = $"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}" + } + ], + }; + } + } + + var resultsLock = new Lock(); + Exception? error = null; + var results = new List(); + var timeout = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), cancellationToken); + using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var cancelEnumerateToken = cancelEnumerate.Token; + var enumerate = Task.Run(async () => + { + try + { + await foreach (var evt in connection.Events.EnumerateAsync( + filter: predicate, + count: limit, + cancellationToken: cancelEnumerateToken)) + { + lock (resultsLock) + { + results.Add(evt); + } + } + } + catch (Exception ex) + { + if (ex.GetBaseException() is not OperationCanceledException) + { + Log.Error(ex, "Exception thrown during search result enumeration"); + } + + lock (resultsLock) + { + error = ex; + } + } + + }, cancellationToken); + + var completed = await Task.WhenAny(enumerate, timeout) == enumerate; + await cancelEnumerate.CancelAsync(); + + EventEntity[] takenResults; + Exception? takenError; + lock (resultsLock) + { + takenResults = results.ToArray(); + takenError = error; + } + + string resultSetStatus; + + var reachedLimit = takenResults.Length == limit; + if (reachedLimit) + { + resultSetStatus = $"Showing the most recent {limit} matching event(s):"; + } + else if (takenError != null) + { + if (takenResults.Length == 0) + { + resultSetStatus = $"The search failed. {takenError}"; + } + else + { + resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError.Message}"; + } + } + else if (completed) + { + if (takenResults.Length == 0) + { + resultSetStatus = "No events matched the search expression."; + } + else + { + resultSetStatus = $"Showing all {takenResults.Length} matching event(s)."; + } + } + else + { + if (takenResults.Length == 0) + { + // FUTURE: point to indexes when it's possible to retrieve index info. + resultSetStatus = "The search timed out before any results were identified. " + + "Retry using narrower time ranges."; + } + else + { + resultSetStatus = $"The search timed out after retrieving {takenResults.Length} matching " + + "event(s). Inspect these results, and if more are required, retry using " + + "narrower time ranges."; + } + } + + var responseText = new StringWriter(); + foreach (var result in takenResults) + { + var resultId = session.ImportSearchResult(result); + var serilogEvent = SearchCommand.ToSerilogEvent(result); + serilogEvent.AddOrUpdateProperty(new LogEventProperty(ResultIdPropertyName, new ScalarValue(resultId))); + serilogEvent.AddOrUpdateProperty(new LogEventProperty(LevelMapping.SurrogateLevelProperty, new ScalarValue(result.Level ?? "Information"))); + SearchResultFormatter.Format(serilogEvent, responseText); + } + + return new CallToolResult + { + Content = + [ + new TextContentBlock { Text = resultSetStatus }, + new TextContentBlock { Text = responseText.ToString() } + ] + }; + } + + + [McpServerTool(Name = "seq_read_search_result", ReadOnly = true, Title = "Read Full Event Details")] + [Description("Read the full details of an event appearing in `seq_search` results, including all property " + + "values and a complete stack trace (if present). The event is formatted precisely as a Seq syntax literal, " + + "using Seq's native data model.")] + [return: Description("A Seq-native object literal representation of the event data.")] + public Task ReadSearchResultJson( + [Description("The result id from the `seq_search` tool.")] + // ReSharper disable once InconsistentNaming + string result_id) + { + if (!session.TryGetSearchResult(result_id, out var result, out var error)) + { + return Task.FromResult(new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = error + } + ] + }); + } + + var resultText = new StringWriter(); + SeqSyntaxFormatter.FormatAsObjectLiteral(result, resultText); + + return Task.FromResult(new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = resultText.ToString() + } + ] + }); + } +} \ No newline at end of file diff --git a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs index adf1f87d..1362716f 100644 --- a/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs +++ b/src/SeqCli/PlainText/LogEvents/LogEventBuilder.cs @@ -59,7 +59,7 @@ static MessageTemplate GetMessageTemplate(IDictionary propertie m is TextSpan ts) { var text = ts.ToStringValue(); - return new MessageTemplate(new MessageTemplateToken[] {new TextToken(text) }); + return new MessageTemplate([new TextToken(text)]); } return NoMessage; diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index df304e8c..afd80598 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -34,6 +34,7 @@ + @@ -55,4 +56,7 @@ + + + diff --git a/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md b/src/SeqCli/Skills/Resources/seq-query-grammar/SKILL.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md new file mode 100644 index 00000000..0b6dd02b --- /dev/null +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -0,0 +1,416 @@ +--- +name: seq-search-and-query +description: Search and query logs and spans in Seq. Use when interacting with Seq. +license: Apache-2.0 +metadata: + author: Datalust and Contributors +--- + +Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +compute tabular, aggregate results from the same data. + +## Data Model + +All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the +`@Start` property. The following built-in properties are supported. The type column uses `?` to indicate properties that +may be undefined for some events. + +| Built in property name | Type | Description | +|------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | +| `@Data` | `object` | A compact internal representation of the event as a single structured object. | +| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | +| `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | +| `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | +| `@Id` | `string` | The event's unique id in Seq. | +| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'error'`, and `'err'` would all be typical values. | +| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | +| `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | +| `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | +| `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | +| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | +| `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | +| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | +| `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | +| `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | + +## Type System + +Stored data and intermediate values in expression evaluation are typed dynamically. Values are one of the following types. + +| Type name | Description | Example literals | +|------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **bool** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | + +In expression evaluation, Seq does not perform any type coercion. Functions and operators that receive invalid arguments +evaluate to _undefined_, which is the absence of a value (_undefined_ has roughly the same semantics as `NULL` in standard SQL). + +## Scalar Functions + +These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like count() and distinct() that work with sets of values. + +| Function signature | Description | Result type | +| --- | --- | --- | +| `Arrived(eventId)` | Evaluates to the arrival order encoded in eventId. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. If any argument is null, the result is undefined. | `number` | + +Bucket(number, err) + +Reduce precision by computing the midpoint of the closest logarithmic bucket. If any argument is non-numeric, the result is undefined. + +Result type: number + +Example + +Bucket(3.141592, 0.001) + +Coalesce(arg0, arg1, ...) + +Evaluates to the first defined, non-null argument. If no argument meets this requirement, Coalesce returns the value of its final argument. + +Result type: any +Concat(str0, str1, ...) + +Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is null, the result is undefined. + +Result type: any +Contains(text, substring) + +Evaluates to true if text contains substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. + +Result type: bool +Supports the ci modifier?: Yes +DatePart(datetime, part, offset) + +Compute the value of part for the date/time datetime at time zone offset offset. Both datetime and offset are 100-nanosecond tick values. If part is not a string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is null, the result is undefined. + +Result type: number + +Example + +DatePart(Now(), 'weekday', OffsetIn('Australia/Brisbane', Now())) + +DateTime(str) + +Attempt to parse the date/time value encoded in the string str. If the value cannot be parsed as a date/time, the result is undefined. If any argument is null, the result is undefined. + +Result type: number +ElementAt(collection, index) + +Access the element of the array or object collection at the index or key index. If any argument is null, the result is undefined. + +Result type: any +Supports the ci modifier?: Yes +EndsWith(text, substring) + +Evaluates to true if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. + +Result type: bool +Supports the ci modifier?: Yes +Every(collection, predicate) + +Evaluates to true if the function predicate evaluates to true for all elements of the array or object collection. If any argument is null, the result is undefined. + +Result type: bool + +Example + +Every(['0.1', '0.1-pre'], |tag| StartsWith(tag, '0.')) + +FromJson(json) + +Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is null, the result is undefined. + +Result type: any +Has(arg) + +Evaluates to true if arg is defined. Otherwise, if arg is undefined, the result is false. + +Result type: bool +IndexOf(text, substring) + +Return the zero-based index of the first occurrence of substring in text. Accepts a /regular expression/ in place of substring. If substring is not present in text, the result is -1. If any argument is null, the result is undefined. + +Result type: number +Supports the ci modifier?: Yes +Keys(obj) + +Evaluates to an array containing the keys of the object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. + +Result type: array +LastIndexOf(text, substring) + +Return the zero-based index of the last occurrence of substring in text. Accepts a /regular expression/ in place of substring. If substring is not present in text, the result is -1. If any argument is null, the result is undefined. + +Result type: number +Supports the ci modifier?: Yes +Length(arg) + +Evaluates to the length of the string or array arg. If any argument is null, the result is undefined. + +Result type: number +Now() + +Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is null, the result is undefined. + +Result type: number +OffsetIn(timezone, instant) + +Determine the offset from UTC in time zone timezone at instant instant. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. If any argument is null, the result is undefined. + +Result type: number + +Example + +OffsetIn('Australia/Brisbane', Now()) + +Replace(text, substring, replacement) + +Replace all occurrences of substring in text with replacement. Accepts a /regular expression/ in place of substring, in which case replacement may use $0 to refer to the match, $1 the first capturing group, and so on. Regular expression replacements use $$ to escape a single dollar sign. If any argument is null, the result is undefined. + +Result type: number +Supports the ci modifier?: Yes +Round(value, places) + +Round value to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. + +Result type: number + +Example + +// Evaluates to 123.5 +Round(123.456, 1) + +Some(collection, predicate) + +Evaluates to true if the function predicate evaluates to true for any element of the array or object collection. If any argument is null, the result is undefined. + +Result type: bool + +Example + +Some(['0.1', '0.1-pre'], |tag| EndsWith(tag, '-pre')) + +StartsWith(text, substring) + +Evaluates to true if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. + +Result type: bool +Supports the ci modifier?: Yes +Substring(str, start, length) + +Evaluates to the substring of string str from the zero-based index start, of length characters. If length is not specified, or exceeds the number of characters remaining after start, the result is the remainder of the string. The result is undefined if start is out of bounds, or if either start or length is negative. If any argument is undefined, the result is undefined. + +Result type: any +TimeOfDay(datetime, offsetHours) + +Compute the time of day of the date/time datetime in the time zone offset offsetHours. If any argument is non-numeric, the result is undefined. + +Result type: number + +Example + +TimeOfDay(Now(), -7) + +TimeSpan(str) + +Attempt to parse the d.HH:mm:ss.f formatted time value encoded in the string str. If the value cannot be parsed as a time, the result is undefined. If any argument is null, the result is undefined. + +Result type: number + +Example + +TimeSpan('1.1:59:59.123') + +ToEventType(str) + +Compute the event type that Seq automatically assigns to @EventType from the message template str. If any argument is null, the result is undefined. + +Result type: any +ToHexString(num) + +Format num as a hexadecimal string, including leading 0x. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. + +Result type: string +ToIsoString(datetime, offset) + +Format datetime as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. + +Result type: string + +Example + +ToIsoString(DateTime('2023-12-20'), 10h) + +ToJson(arg) + +Convert the value arg to JSON. If the argument is undefined, the result is undefined. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. + +Result type: string +ToLower(str) + +Convert string str to lowercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. + +Result type: string +ToNumber(str) + +Parse string str as a number. If any argument is null, the result is undefined. + +Result type: number +TotalMilliseconds(timespan) + +Evaluates to the total number of milliseconds represented by the time span timespan. If timespan is a number, it will be interpreted as containing 100-nanosecond ticks. If timespan is a string, it will be parsed in the same manner as performed by TimeSpan(). If any argument is null, the result is undefined. + +Result type: number + +Example + +TotalMilliseconds(1s) + +ToTimeString(timespan) + +Format timespan as an d.HH:mm:ss.f string. The timespan argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. + +Result type: string + +Example + +ToTimeString(1h) + +ToUpper(str) + +Convert string str to uppercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. + +Result type: string +TypeOf(arg) + +Returns the type of value, either 'object', 'array', 'string', 'number', 'bool', 'null', or 'undefined'. + +Result type: string +Values(obj) + +Evaluates to an array containing the values of the members of object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. + +Result type: array +Operator - + +Subtract one number from another. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator - (prefix) + +Negate a number. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator * + +Multiply two numbers. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator / + +Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator % + +Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator ^ + +Raise a number to the specified power. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator + + +Add two numbers. If any argument is non-numeric, the result is undefined. + +Result type: number +Operator < + +Compare two numbers and return true if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +Operator <= + +Compare two numbers and return true if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +Operator <> + +Compare two values, returning true if the values are unequal, and false otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is true if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. + +Result type: any +Supports the ci modifier?: Yes +Operator = + +Compare two values, returning true if the values are equal, and false otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. + +Result type: any +Supports the ci modifier?: Yes +Operator > + +Compare two numbers and return true if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +Operator >= + +Compare two numbers and return true if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. + +Result type: bool +Operator and + +The logical AND operator. The result of a and b is true if and only if both a and b are true; the result is otherwise false. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. + +Result type: bool +Operator not (prefix) + +Logical NOT. Evaluates to true only if the operand is false, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. + +Result type: bool +Operator or + +The logical OR operator. The result of a or b is true if either a or b is true; otherwise the result is false. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. + +Result type: bool + +## Aggregate Functions (Queries Only) + +## Cheat Sheet + + +## Grammar + +### Expressions + +### Queries + + +## Gotchas + + - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values. + - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar + and built-ins described above. + - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from + **most recent** to least recent. + - Data in Seq servers don't always use OpenTelemetry semantic conventions. When searching or querying, only use property + names from the built-ins described above, that appear on search results, or that are returned from the search result + schema tool. + - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names + to be used. + - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an + embedded literal single quote. Backslash escaping is not recognized. + - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (`ulong` with 100 ns + resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will + fail: use duration literals for durations, and the `DateTime` function + to convert from ISO-8601 strings. + - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use + JavaScript's system of comparisons. + - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. + \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 7f3a9623..00546bbf 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -19,7 +19,7 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun { var exit = runner.Exec("skills install -a test-agent"); Assert.Equal(0, exit); - Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-query-grammar/SKILL.md"))); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); } finally { From f1784c63c6b6f009f7a29b0714ff6c8dfac85bea Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 12:10:02 +1000 Subject: [PATCH 35/98] More skill WIP --- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 15 + src/SeqCli/Mcp/McpSession.cs | 48 ++ src/SeqCli/Mcp/Tools/Search/SearchTool.cs | 10 + .../Resources/seq-search-and-query/SKILL.md | 443 ++++-------------- 4 files changed, 171 insertions(+), 345 deletions(-) diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index a1ee6147..2a143af0 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -188,4 +188,19 @@ static void WritePropertiesObject(TextWriter output, List mem { WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); } + + public static string MakeIdentifier(string prefixPath, bool optionalPrefix, string propertyName) + { + if (IdentifierRegex().IsMatch(propertyName)) + { + // TODO, exclude keywords here. + if (optionalPrefix) + return propertyName; + return $"{prefixPath}.{propertyName}"; + } + + var sw = new StringWriter(); + WriteValue(sw, propertyName); + return $"{prefixPath}[{sw}]"; + } } diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 61df7c13..5a6988c8 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -4,6 +4,9 @@ using System.Threading; using Seq.Api.Model.Events; using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using SeqCli.Mcp.Formatting; namespace SeqCli.Mcp; @@ -73,4 +76,49 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt return true; } } + + public IEnumerable EnumerateUserPropertyNames() + { + List all; + lock (_sync) + { + all = _eventIdToResult.Values.Select(pair => pair.Item2).ToList(); + } + + var seen = new HashSet(); + foreach (var evt in all) + { + foreach (var property in evt.Properties) + { + foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) + yield return unique; + } + foreach (var property in evt.Scope) + { + foreach (var unique in EnumerateUnique(seen, "@Scope", false, property.Name, property.Value, 1)) + yield return unique; + } + foreach (var property in evt.Resource) + { + foreach (var unique in EnumerateUnique(seen, "@Resource", false, property.Name, property.Value, 1)) + yield return unique; + } + } + } + + static IEnumerable EnumerateUnique(HashSet seen, string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) + { + var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, optionalPrefix, propertyName); + if (seen.Add(name)) + yield return name; + + if (depth < 5 && propertyValue is JObject jo) + { + foreach (var child in jo.Properties()) + { + foreach (var childName in EnumerateUnique(seen, name, false, child.Name, child.Value, depth + 1)) + yield return childName; + } + } + } } \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs index 0080f6db..c7a82ea0 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchTool.cs @@ -3,6 +3,7 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; @@ -255,4 +256,13 @@ public Task ReadSearchResultJson( ] }); } + + [McpServerTool(Name = "seq_inspect_schema", ReadOnly = true, Title = "Inspect Event Schema")] + [Description("List the user-defined top-level, scope, and resource property names observed on events " + + "so far in this session. Only events retrieved in search results are considered.")] + [return: Description("A list containing Seq syntax-formatted property names.")] + public Task InspectSchema() + { + return Task.FromResult(session.EnumerateUserPropertyNames().OrderBy(n => n).ToArray()); + } } \ No newline at end of file diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 0b6dd02b..c9e75974 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -12,8 +12,7 @@ compute tabular, aggregate results from the same data. ## Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the -`@Start` property. The following built-in properties are supported. The type column uses `?` to indicate properties that -may be undefined for some events. +`@Start` property. The following built-in properties are supported. | Built in property name | Type | Description | |------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| @@ -38,349 +37,103 @@ may be undefined for some events. ## Type System -Stored data and intermediate values in expression evaluation are typed dynamically. Values are one of the following types. - -| Type name | Description | Example literals | -|------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| -| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | -| **bool** | The atoms `true` and `false`. | `true`, `false` | -| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | -| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | -| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | - -In expression evaluation, Seq does not perform any type coercion. Functions and operators that receive invalid arguments -evaluate to _undefined_, which is the absence of a value (_undefined_ has roughly the same semantics as `NULL` in standard SQL). - -## Scalar Functions - -These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like count() and distinct() that work with sets of values. - -| Function signature | Description | Result type | -| --- | --- | --- | -| `Arrived(eventId)` | Evaluates to the arrival order encoded in eventId. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. If any argument is null, the result is undefined. | `number` | - -Bucket(number, err) - -Reduce precision by computing the midpoint of the closest logarithmic bucket. If any argument is non-numeric, the result is undefined. - -Result type: number - -Example - -Bucket(3.141592, 0.001) - -Coalesce(arg0, arg1, ...) - -Evaluates to the first defined, non-null argument. If no argument meets this requirement, Coalesce returns the value of its final argument. - -Result type: any -Concat(str0, str1, ...) - -Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is null, the result is undefined. - -Result type: any -Contains(text, substring) - -Evaluates to true if text contains substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. - -Result type: bool -Supports the ci modifier?: Yes -DatePart(datetime, part, offset) - -Compute the value of part for the date/time datetime at time zone offset offset. Both datetime and offset are 100-nanosecond tick values. If part is not a string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is null, the result is undefined. - -Result type: number - -Example - -DatePart(Now(), 'weekday', OffsetIn('Australia/Brisbane', Now())) - -DateTime(str) - -Attempt to parse the date/time value encoded in the string str. If the value cannot be parsed as a date/time, the result is undefined. If any argument is null, the result is undefined. - -Result type: number -ElementAt(collection, index) - -Access the element of the array or object collection at the index or key index. If any argument is null, the result is undefined. - -Result type: any -Supports the ci modifier?: Yes -EndsWith(text, substring) - -Evaluates to true if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. - -Result type: bool -Supports the ci modifier?: Yes -Every(collection, predicate) - -Evaluates to true if the function predicate evaluates to true for all elements of the array or object collection. If any argument is null, the result is undefined. - -Result type: bool - -Example - -Every(['0.1', '0.1-pre'], |tag| StartsWith(tag, '0.')) - -FromJson(json) - -Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is null, the result is undefined. - -Result type: any -Has(arg) - -Evaluates to true if arg is defined. Otherwise, if arg is undefined, the result is false. - -Result type: bool -IndexOf(text, substring) - -Return the zero-based index of the first occurrence of substring in text. Accepts a /regular expression/ in place of substring. If substring is not present in text, the result is -1. If any argument is null, the result is undefined. - -Result type: number -Supports the ci modifier?: Yes -Keys(obj) - -Evaluates to an array containing the keys of the object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. - -Result type: array -LastIndexOf(text, substring) - -Return the zero-based index of the last occurrence of substring in text. Accepts a /regular expression/ in place of substring. If substring is not present in text, the result is -1. If any argument is null, the result is undefined. - -Result type: number -Supports the ci modifier?: Yes -Length(arg) - -Evaluates to the length of the string or array arg. If any argument is null, the result is undefined. - -Result type: number -Now() - -Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is null, the result is undefined. - -Result type: number -OffsetIn(timezone, instant) - -Determine the offset from UTC in time zone timezone at instant instant. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. If any argument is null, the result is undefined. - -Result type: number - -Example - -OffsetIn('Australia/Brisbane', Now()) - -Replace(text, substring, replacement) - -Replace all occurrences of substring in text with replacement. Accepts a /regular expression/ in place of substring, in which case replacement may use $0 to refer to the match, $1 the first capturing group, and so on. Regular expression replacements use $$ to escape a single dollar sign. If any argument is null, the result is undefined. - -Result type: number -Supports the ci modifier?: Yes -Round(value, places) - -Round value to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. - -Result type: number - -Example - -// Evaluates to 123.5 -Round(123.456, 1) - -Some(collection, predicate) - -Evaluates to true if the function predicate evaluates to true for any element of the array or object collection. If any argument is null, the result is undefined. - -Result type: bool - -Example - -Some(['0.1', '0.1-pre'], |tag| EndsWith(tag, '-pre')) - -StartsWith(text, substring) - -Evaluates to true if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is null, the result is undefined. - -Result type: bool -Supports the ci modifier?: Yes -Substring(str, start, length) - -Evaluates to the substring of string str from the zero-based index start, of length characters. If length is not specified, or exceeds the number of characters remaining after start, the result is the remainder of the string. The result is undefined if start is out of bounds, or if either start or length is negative. If any argument is undefined, the result is undefined. - -Result type: any -TimeOfDay(datetime, offsetHours) - -Compute the time of day of the date/time datetime in the time zone offset offsetHours. If any argument is non-numeric, the result is undefined. - -Result type: number - -Example - -TimeOfDay(Now(), -7) - -TimeSpan(str) - -Attempt to parse the d.HH:mm:ss.f formatted time value encoded in the string str. If the value cannot be parsed as a time, the result is undefined. If any argument is null, the result is undefined. - -Result type: number - -Example - -TimeSpan('1.1:59:59.123') - -ToEventType(str) - -Compute the event type that Seq automatically assigns to @EventType from the message template str. If any argument is null, the result is undefined. - -Result type: any -ToHexString(num) - -Format num as a hexadecimal string, including leading 0x. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. - -Result type: string -ToIsoString(datetime, offset) - -Format datetime as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. - -Result type: string - -Example - -ToIsoString(DateTime('2023-12-20'), 10h) - -ToJson(arg) - -Convert the value arg to JSON. If the argument is undefined, the result is undefined. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. - -Result type: string -ToLower(str) - -Convert string str to lowercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. - -Result type: string -ToNumber(str) - -Parse string str as a number. If any argument is null, the result is undefined. - -Result type: number -TotalMilliseconds(timespan) - -Evaluates to the total number of milliseconds represented by the time span timespan. If timespan is a number, it will be interpreted as containing 100-nanosecond ticks. If timespan is a string, it will be parsed in the same manner as performed by TimeSpan(). If any argument is null, the result is undefined. - -Result type: number - -Example - -TotalMilliseconds(1s) - -ToTimeString(timespan) - -Format timespan as an d.HH:mm:ss.f string. The timespan argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. - -Result type: string - -Example - -ToTimeString(1h) - -ToUpper(str) - -Convert string str to uppercase. To compare strings in a case-insensitive manner, use the equality operator and ci modifier instead. The result is undefined if str is not a string. If any argument is null, the result is undefined. - -Result type: string -TypeOf(arg) - -Returns the type of value, either 'object', 'array', 'string', 'number', 'bool', 'null', or 'undefined'. - -Result type: string -Values(obj) - -Evaluates to an array containing the values of the members of object obj. The result is undefined if obj is not an object. If any argument is null, the result is undefined. - -Result type: array -Operator - - -Subtract one number from another. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator - (prefix) - -Negate a number. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator * - -Multiply two numbers. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator / - -Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator % - -Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator ^ - -Raise a number to the specified power. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator + - -Add two numbers. If any argument is non-numeric, the result is undefined. - -Result type: number -Operator < - -Compare two numbers and return true if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -Operator <= - -Compare two numbers and return true if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -Operator <> - -Compare two values, returning true if the values are unequal, and false otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is true if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. - -Result type: any -Supports the ci modifier?: Yes -Operator = - -Compare two values, returning true if the values are equal, and false otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. - -Result type: any -Supports the ci modifier?: Yes -Operator > - -Compare two numbers and return true if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -Operator >= - -Compare two numbers and return true if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. - -Result type: bool -Operator and - -The logical AND operator. The result of a and b is true if and only if both a and b are true; the result is otherwise false. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. - -Result type: bool -Operator not (prefix) - -Logical NOT. Evaluates to true only if the operand is false, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. - -Result type: bool -Operator or - -The logical OR operator. The result of a or b is true if either a or b is true; otherwise the result is false. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. - -Result type: bool - -## Aggregate Functions (Queries Only) +Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. + +| Type name | Description | Example literals | +|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **boolean** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | + +In expression evaluation, Seq does not perform any type coercion. + +The results of functions and operators that receive invalid arguments are undefined, which is the absence of a value +(_undefined_ has roughly the same "poison" semantics as `NULL` in standard SQL). + +Type notation in this document column uses the suffix `?` on a type name to indicate values that may be undefined. +The synthetic type name `any` is used as an alias for `null | boolean | number | array | object`. + +## Scalar Functions and Operators + +These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. + +| Function signature | Description | Result type | +|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | `number` | +| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | `number` | +| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | `any` | +| `Concat(str0, str1, ...)` | Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is `null`, the result is undefined. | `any` | +| `Contains(text, substring)` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | +| `DatePart(datetime, part, offset)` | Compute the value of part for the date/time `datetime` at time zone offset offset. Both `datetime` and `offset` are 100-nanosecond tick values. If part is not a string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is `null`, the result is undefined. | `number` | +| `DateTime(str)` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. If any argument is `null`, the result is undefined. | `number` | +| `ElementAt(collection, index)` | Access the element of the array or object collection at the index or key index. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `any` | +| `EndsWith(text, substring)` | Evaluates to `true` if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | +| `Every(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | +| `FromJson(json)` | Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is `null`, the result is undefined. | `any` | +| `Has(arg)` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | `bool` | +| `IndexOf(text, substring)` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | +| `Keys(obj)` | Evaluates to an array containing the keys of the object `obj`. The result is undefined if `obj` is not an object. If any argument is `null`, the result is undefined. | `array` | +| `LastIndexOf(text, substring)` | Return the zero-based index of the last occurrence of substring in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | +| `Length(arg)` | Evaluates to the length of the string or array `arg`. If any argument is `null`, the result is undefined. | `number` | +| `Now()` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is `null`, the result is undefined. | `number` | +| `OffsetIn(timezone, instant)` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. If any argument is `null`, the result is undefined. | `number` | +| `Replace(text, substring, replacement)` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | +| `Round(value, places)` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. | `number` | +| `Some(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | +| `StartsWith(text, substring)` | Evaluates to `true` if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | +| `Substring(str, start, length)` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. If any argument is undefined, the result is undefined. | `any` | +| `TimeOfDay(datetime, offsetHours)` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. If any argument is non-numeric, the result is undefined. | `number` | +| `TimeSpan(str)` | Attempt to parse the d.HH:mm:ss.f formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. If any argument is `null`, the result is undefined. | `number` | +| `ToEventType(str)` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. If any argument is `null`, the result is undefined. | `any` | +| `ToHexString(num)` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. | `string` | +| `ToIsoString(datetime, offset)` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. | `string` | +| `ToJson(arg)` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. | `string` | +| `ToLower(str)` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if `str` is not a string. | `string` | +| `ToNumber(str)` | Parse string str as a number. If any argument is `null`, the result is undefined. | `number` | +| `TotalMilliseconds(timespan)` | Evaluates to the total number of milliseconds represented by the time span timespan. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. If any argument is `null`, the result is undefined. | `number` | +| `ToTimeString(timespan)` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. | `string` | +| `ToUpper(str)` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if str is not a string. | `string` | +| `TypeOf(arg)` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'bool'`, `'null'`, or `'undefined'`. | `string` | +| `Values(obj)` | Evaluates to an array containing the values of the members of object `obj`. The result is undefined if `obj` is not an object. | `array` | +| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | `number` | +| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | `any` | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | `any` | +| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | +| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | `bool` | +| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | `bool` | +| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | `bool` | + +## Aggregate Functions + +`select` queries (see grammar below) have access to the following aggregate functions. + +| Aggregate function signature | Description | Example | | Result type | +|------------------------------|----------------------------------------------------------------------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| +| `all(expr)` | Given an expression, return `true` if the expression is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr)` | Given an expression, return `true` if the expression is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr, n)` | Given an expression, compute the last N values that appear for that expression. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property)` | Given a property name, computes the number of events that have a non-null value for that property. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr)` | Given a property name or expression, computes the set of distinct values for that expression. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr)` | Given an expression, returns the value of that expression applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr)` | Given an expression, returns the value of that expression applied to the last events in the target range. | `last(Elapsed)` | +| `interval()` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr)` | Given a numeric expression or property name, computes the smallest value for that expression. | `min(Elapsed / 1000)` | +| `max(expr)` | Given a numeric expression or property name, computes the largest value for that expression. | `max(Elapsed / 1000)` | +| `mean(expr)` | Computes the arithmetic mean (average) of a numeric expression or property, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| `percentile(expr, p [, err])` | Given an expression and a percentage `p`, calculates the value of the expression at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 [, err = 0.01])` | +| `sum(expr)` | Given an expression or property name, calculates the sum of that value. Non-numeric results are ignored. | `sum(ItemsOrdered)` | +| `top(expr, n)` | Select the first N values of an expression. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | ## Cheat Sheet From 588230d55d4c58342658ae427b78d2ff0bd4fc6e Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 13:38:46 +1000 Subject: [PATCH 36/98] More skill and tool WIP --- .claude/skills/seq-search-and-query/SKILL.md | 296 ++++++++++++++++++ src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 2 +- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 10 +- src/SeqCli/Mcp/McpSession.cs | 4 +- ...earchTool.cs => SearchAndQueryToolType.cs} | 32 +- .../Resources/seq-search-and-query/SKILL.md | 283 ++++++++++++----- src/SeqCli/Skills/SkillInstaller.cs | 2 +- 7 files changed, 545 insertions(+), 84 deletions(-) rename src/SeqCli/Mcp/Tools/Search/{SearchTool.cs => SearchAndQueryToolType.cs} (89%) diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index f6467791..0b425248 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -6,3 +6,299 @@ metadata: author: Datalust and Contributors --- +Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +compute tabular, aggregate results from the same data. + +> This skill does not currently cover interactions with metrics (the `series` storage object). + +## Data Model + +All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the +`@Start` property. The following built-in properties are supported. + +| Built in property name | Type | Description | +|------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | +| `@Data` | `object` | A compact internal representation of the event as a single structured object. | +| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | +| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | +| `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | +| `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | +| `@Id` | `string` | The event's unique id in Seq. | +| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'error'`, and `'err'` would all be typical values. | +| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | +| `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | +| `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | +| `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | +| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | +| `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | +| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | +| `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | +| `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | + +## Type System + +Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. + +| Type name | Description | Example literals | +|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **boolean** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | + +In expression evaluation, Seq does not perform any type coercion. + +The results of functions and operators that receive invalid arguments are undefined, which is the absence of a value +(_undefined_ has roughly the same "poison" semantics as `NULL` in standard SQL). + +Type notation in this document column uses the suffix `?` on a type name to indicate values that may be undefined. +The synthetic type name `any` is used as an alias for `null | boolean | number | array | object`. + +## Scalar Functions and Operators + +These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. + +| Function signature | Description | +|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | +| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | +| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | +| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | +| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | +| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | +| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | +| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | +| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | +| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | +| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | +| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | +| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | +| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | +| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | +| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | +| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | +| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | +| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | +| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | +| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | +| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | +| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | +| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | +| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | +| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | + +## Aggregate Functions + +`select` queries (see grammar below) have access to the following aggregate functions. + +| Aggregate function signature | Description | Example | +|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| +| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | +| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | +| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | +| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | +| `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | +| `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | + +## Grammar + +### Base + +```ebnf +identifier = ( letter | '_' ) , { letter | digit | '_' } ; +built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +letter = ? any Unicode letter ? ; +digit = ? any Unicode digit ? ; +string_literal = "'" , { string_char } , "'" ; +string_char = "''" | ? any character except single quote ? ; +number = natural , [ '.' , natural ] ; +hex_number = '0x' , hex_digit , { hex_digit } ; +natural = digit , { digit } ; +hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' + | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; +duration = { natural , time_unit }- ; +time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; +regular_expression = '/' , { regex_char } , '/' ; +regex_char = '\/' | ? any character except '/' ? ; +``` + +### Expression + +``` +Expr = Disjunction ; +Disjunction = Conjunction , { 'or' , Conjunction } ; +Conjunction = Comparison , { 'and' , Comparison } ; +Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; +comparison_op = 'not' , 'like' + | 'like' + | 'not' , 'in' + | 'in' + | '<=' | '<>' | '<' + | '>=' | '>' + | '=' ; +Comparand = Term , { ( '+' | '-' ) , Term } ; +Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; +InnerTerm = Operand , { '^' , Operand } ; + +Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; +unary_op = '-' | 'not' ; +null_test = 'null' | 'not' , 'null' ; +Path = Factor , { path_step } ; +path_step = '.' , identifier + | '[' , ( wildcard | Expr ) , ']' ; +wildcard = '?' | '*' ; +Factor = '(' , Expr , ')' + | Item ; +Item = Property + | Literal + | Function + | ArrayLiteral + | ObjectLiteral + | Conditional + | Block + | Lambda + | Variable ; +Property = built_in_identifier + | identifier ; (* when not followed by '(' *) +Literal = string_literal + | number + | hex_number + | duration + | regular_expression + | 'true' + | 'false' + | 'null' ; +Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; +function_name = identifier + | 'and' | 'not' | 'or' ; +arg_list = '*' (* only valid for count(*) *) + | [ Expr , { ',' , Expr } ] ; +ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; +ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; +ObjectMember = ( identifier | string_literal ) , ':' , Expr ; +Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; +Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; +Binding = identifier , ':' , Expr ; +Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; +Variable = variable ; +``` + +**Disambiguation:** The `/` character introduces a regular expression when it appears at the +start of input, or when the preceding token is an operator or opening delimiter — specifically +one of: `and`, `or`, `not`, `(`, `[`, `,`, `=`, `<>`, `like`, `>`, `>=`, `<`, `<=`, `in`, +`is`, `&&`, `||`, `!=`, `==`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is +the division operator. + +### Queries + +```ebnf +Query = [ ExplainClause ] + SelectClause + [ IntoClause ] + [ FromClause ] + [ WhereClause ] + [ GroupByClause ] + [ HavingClause ] + [ OrderByClause ] + [ LimitClause ] + [ ForClause ] ; +ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; +SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; +SelectColumn = '*' + | Expr , [ 'as' , identifier ] ; +IntoClause = 'into' , variable ; +FromClause = 'from' , source , { LateralJoin } ; +source = 'stream' | 'series' ; +LateralJoin = 'lateral' , Expr , 'as' , identifier ; +WhereClause = 'where' , Expr ; +GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; +Grouping = TimeGrouping + | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; +TimeGrouping = 'time' , '(' , duration , ')' ; +HavingClause = 'having' , Expr ; +OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; +Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; +LimitClause = 'limit' , natural ; +ForClause = 'for' , ForOption , { ',' , ForOption } ; +ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; +``` + +Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains +metric samples. + +## Search Expression Examples + +| Example | Effect | +|---|-----------------------------------------------------| +| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | +| `Has(@Start)` | Match all spans (excludes log events). | +| `@Message like '%overflow%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | + +## Gotchas + + - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values. + - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar + and built-ins described above. + - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from + **most recent** to least recent. + - Data in Seq servers don't always use OpenTelemetry semantic conventions. When searching or querying, only use property + names from the built-ins described above, that appear on search results, or that are returned from the search result + schema tool. + - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names + to be used. + - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an + embedded literal single quote. Backslash escaping is not recognized. + - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (`ulong` with 100 ns + resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will + fail: use duration literals for durations, and the `DateTime` function + to convert from ISO-8601 strings. + - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use + JavaScript's system of comparisons. + - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. + - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. + - Regular expression evaluation is extremely expensive, avoid these as much as possible. + \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 5bcb900b..cf8ac5b4 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -50,7 +50,7 @@ protected override async Task Run() .AddMcpServer() .WithStdioServerTransport() .WithTools([ - typeof(SearchTool) + typeof(SearchAndQueryToolType) ]); await builder.Build().RunAsync(); diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 2a143af0..2e7c0266 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -19,6 +19,13 @@ static partial class SeqSyntaxFormatter [GeneratedRegex("[_a-zA-Z][_a-zA-Z0-9]*")] private static partial Regex IdentifierRegex(); + + static readonly HashSet Keywords = new(StringComparer.OrdinalIgnoreCase) + { + "and", "ci", "else", "false", "if", "in", "is", "let", "like", "not", "null", "or", "then", "true", + "analyze", "as", "asc", "by", "desc", "explain", "for", "from", "group", "having", "into", "lateral", + "limit", "lower", "order", "select", "where" + }; public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) { @@ -193,8 +200,7 @@ public static string MakeIdentifier(string prefixPath, bool optionalPrefix, stri { if (IdentifierRegex().IsMatch(propertyName)) { - // TODO, exclude keywords here. - if (optionalPrefix) + if (optionalPrefix && !Keywords.Contains(propertyName)) return propertyName; return $"{prefixPath}.{propertyName}"; } diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 5a6988c8..0a1e6a60 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -77,7 +77,7 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt } } - public IEnumerable EnumerateUserPropertyNames() + public IEnumerable EnumerateUserPropertyNames(CancellationToken cancellationToken) { List all; lock (_sync) @@ -88,6 +88,8 @@ public IEnumerable EnumerateUserPropertyNames() var seen = new HashSet(); foreach (var evt in all) { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var property in evt.Properties) { foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs similarity index 89% rename from src/SeqCli/Mcp/Tools/Search/SearchTool.cs rename to src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index c7a82ea0..1be04f11 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchTool.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -23,7 +23,7 @@ namespace SeqCli.Mcp.Tools.Search; [McpServerToolType] -class SearchTool(McpSession session, SeqConnection connection) +class SearchAndQueryToolType(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( @@ -36,7 +36,7 @@ class SearchTool(McpSession session, SeqConnection connection) "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + "skill when calling this tool.")] [return: Description("Search results and status information.")] - public async Task SearchEvents( + public async Task SearchEventsAsync( [Description("The maximum number of events to return.")] [Range(1, 1000)] int limit, @@ -222,7 +222,7 @@ public async Task SearchEvents( "values and a complete stack trace (if present). The event is formatted precisely as a Seq syntax literal, " + "using Seq's native data model.")] [return: Description("A Seq-native object literal representation of the event data.")] - public Task ReadSearchResultJson( + public Task ReadSearchResultJsonAsync( [Description("The result id from the `seq_search` tool.")] // ReSharper disable once InconsistentNaming string result_id) @@ -261,8 +261,30 @@ public Task ReadSearchResultJson( [Description("List the user-defined top-level, scope, and resource property names observed on events " + "so far in this session. Only events retrieved in search results are considered.")] [return: Description("A list containing Seq syntax-formatted property names.")] - public Task InspectSchema() + public Task InspectSchemaAsync(CancellationToken cancellationToken) { - return Task.FromResult(session.EnumerateUserPropertyNames().OrderBy(n => n).ToArray()); + return Task.FromResult(session.EnumerateUserPropertyNames(cancellationToken).OrderBy(n => n).ToArray()); + } + + [McpServerTool(Name = "seq_query", ReadOnly = true, Title = "Evaluate a Query over Logs, Spans, or Metric Samples")] + [Description("Evaluate a Seq query, producing tabular results. Use the `seq-search-and-query` " + + "skill when calling this tool.")] + [return: Description("Query results and status information.")] + public async Task QueryAsync( + [Description("A Seq query language query.")] + string query, + CancellationToken cancellationToken) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = "The query tool is not implemented." + } + ] + }; } } \ No newline at end of file diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index c9e75974..0b425248 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -9,6 +9,8 @@ metadata: Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to compute tabular, aggregate results from the same data. +> This skill does not currently cover interactions with metrics (the `series` storage object). + ## Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the @@ -18,6 +20,7 @@ All events stored in Seq use the same data model. Spans are only distinguished f |------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | | `@Data` | `object` | A compact internal representation of the event as a single structured object. | +| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | | `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | | `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | | `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | @@ -59,91 +62,221 @@ The synthetic type name `any` is used as an alias for `null | boolean | number | These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. -| Function signature | Description | Result type | -|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | `number` | -| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | `number` | -| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | `any` | -| `Concat(str0, str1, ...)` | Concatenate all string arguments. No type coercion is performed: the result is undefined if any argument is not a string. If any argument is `null`, the result is undefined. | `any` | -| `Contains(text, substring)` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | -| `DatePart(datetime, part, offset)` | Compute the value of part for the date/time `datetime` at time zone offset offset. Both `datetime` and `offset` are 100-nanosecond tick values. If part is not a string, or not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. If any argument is `null`, the result is undefined. | `number` | -| `DateTime(str)` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. If any argument is `null`, the result is undefined. | `number` | -| `ElementAt(collection, index)` | Access the element of the array or object collection at the index or key index. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `any` | -| `EndsWith(text, substring)` | Evaluates to `true` if text ends with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | -| `Every(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | -| `FromJson(json)` | Parse the JSON-encoded string json. If json is not a string, or is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. If any argument is `null`, the result is undefined. | `any` | -| `Has(arg)` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | `bool` | -| `IndexOf(text, substring)` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | -| `Keys(obj)` | Evaluates to an array containing the keys of the object `obj`. The result is undefined if `obj` is not an object. If any argument is `null`, the result is undefined. | `array` | -| `LastIndexOf(text, substring)` | Return the zero-based index of the last occurrence of substring in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | -| `Length(arg)` | Evaluates to the length of the string or array `arg`. If any argument is `null`, the result is undefined. | `number` | -| `Now()` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. If any argument is `null`, the result is undefined. | `number` | -| `OffsetIn(timezone, instant)` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. If any argument is `null`, the result is undefined. | `number` | -| `Replace(text, substring, replacement)` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `number` | -| `Round(value, places)` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. If any argument is non-numeric, the result is undefined. | `number` | -| `Some(collection, predicate)` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object collection. If any argument is `null`, the result is undefined. | `bool` | -| `StartsWith(text, substring)` | Evaluates to `true` if text starts with substring. Accepts a /regular expression/ in place of substring. If any argument is `null`, the result is undefined. Supports the `ci` modifier. | `bool` | -| `Substring(str, start, length)` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. If any argument is undefined, the result is undefined. | `any` | -| `TimeOfDay(datetime, offsetHours)` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. If any argument is non-numeric, the result is undefined. | `number` | -| `TimeSpan(str)` | Attempt to parse the d.HH:mm:ss.f formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. If any argument is `null`, the result is undefined. | `number` | -| `ToEventType(str)` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. If any argument is `null`, the result is undefined. | `any` | -| `ToHexString(num)` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. If any argument is non-numeric, the result is undefined. | `string` | -| `ToIsoString(datetime, offset)` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. If any argument is non-numeric, the result is undefined. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. If any argument is non-numeric, the result is undefined. | `string` | -| `ToJson(arg)` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. If any argument is undefined, the result is undefined. | `string` | -| `ToLower(str)` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if `str` is not a string. | `string` | -| `ToNumber(str)` | Parse string str as a number. If any argument is `null`, the result is undefined. | `number` | -| `TotalMilliseconds(timespan)` | Evaluates to the total number of milliseconds represented by the time span timespan. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. If any argument is `null`, the result is undefined. | `number` | -| `ToTimeString(timespan)` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. If any argument is non-numeric, the result is undefined. | `string` | -| `ToUpper(str)` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. The result is undefined if str is not a string. | `string` | -| `TypeOf(arg)` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'bool'`, `'null'`, or `'undefined'`. | `string` | -| `Values(obj)` | Evaluates to an array containing the values of the members of object `obj`. The result is undefined if `obj` is not an object. | `array` | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | `number` | -| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | `any` | -| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | `any` | -| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | `bool` | -| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | `bool` | -| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | `bool` | -| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | `bool` | +| Function signature | Description | +|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | +| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | +| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | +| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | +| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | +| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | +| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | +| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | +| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | +| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | +| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | +| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | +| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | +| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | +| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | +| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | +| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | +| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | +| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | +| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | +| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | +| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | +| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | +| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | +| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | +| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | +| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | ## Aggregate Functions `select` queries (see grammar below) have access to the following aggregate functions. -| Aggregate function signature | Description | Example | | Result type | -|------------------------------|----------------------------------------------------------------------------------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------| -| `all(expr)` | Given an expression, return `true` if the expression is `true` for all events in the stream. | `all(@Level = 'Error')` | -| `any(expr)` | Given an expression, return `true` if the expression is `true` for any event in the stream. | `any(@Level = 'Error')` | -| `bottom(expr, n)` | Given an expression, compute the last N values that appear for that expression. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | -| `count(property)` | Given a property name, computes the number of events that have a non-null value for that property. The special property name `*` can be used to count all events. | `count(*)` | -| `distinct(expr)` | Given a property name or expression, computes the set of distinct values for that expression. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | -| `first(expr)` | Given an expression, returns the value of that expression applied to the first events in the target range. | `first(Elapsed)` | -| `last(expr)` | Given an expression, returns the value of that expression applied to the last events in the target range. | `last(Elapsed)` | -| `interval()` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | -| `min(expr)` | Given a numeric expression or property name, computes the smallest value for that expression. | `min(Elapsed / 1000)` | -| `max(expr)` | Given a numeric expression or property name, computes the largest value for that expression. | `max(Elapsed / 1000)` | -| `mean(expr)` | Computes the arithmetic mean (average) of a numeric expression or property, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | -| `percentile(expr, p [, err])` | Given an expression and a percentage `p`, calculates the value of the expression at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 [, err = 0.01])` | -| `sum(expr)` | Given an expression or property name, calculates the sum of that value. Non-numeric results are ignored. | `sum(ItemsOrdered)` | -| `top(expr, n)` | Select the first N values of an expression. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | - -## Cheat Sheet - +| Aggregate function signature | Description | Example | +|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| +| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | +| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | +| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | +| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | +| `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | +| `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | ## Grammar -### Expressions +### Base + +```ebnf +identifier = ( letter | '_' ) , { letter | digit | '_' } ; +built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +letter = ? any Unicode letter ? ; +digit = ? any Unicode digit ? ; +string_literal = "'" , { string_char } , "'" ; +string_char = "''" | ? any character except single quote ? ; +number = natural , [ '.' , natural ] ; +hex_number = '0x' , hex_digit , { hex_digit } ; +natural = digit , { digit } ; +hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' + | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; +duration = { natural , time_unit }- ; +time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; +regular_expression = '/' , { regex_char } , '/' ; +regex_char = '\/' | ? any character except '/' ? ; +``` + +### Expression + +``` +Expr = Disjunction ; +Disjunction = Conjunction , { 'or' , Conjunction } ; +Conjunction = Comparison , { 'and' , Comparison } ; +Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; +comparison_op = 'not' , 'like' + | 'like' + | 'not' , 'in' + | 'in' + | '<=' | '<>' | '<' + | '>=' | '>' + | '=' ; +Comparand = Term , { ( '+' | '-' ) , Term } ; +Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; +InnerTerm = Operand , { '^' , Operand } ; + +Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; +unary_op = '-' | 'not' ; +null_test = 'null' | 'not' , 'null' ; +Path = Factor , { path_step } ; +path_step = '.' , identifier + | '[' , ( wildcard | Expr ) , ']' ; +wildcard = '?' | '*' ; +Factor = '(' , Expr , ')' + | Item ; +Item = Property + | Literal + | Function + | ArrayLiteral + | ObjectLiteral + | Conditional + | Block + | Lambda + | Variable ; +Property = built_in_identifier + | identifier ; (* when not followed by '(' *) +Literal = string_literal + | number + | hex_number + | duration + | regular_expression + | 'true' + | 'false' + | 'null' ; +Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; +function_name = identifier + | 'and' | 'not' | 'or' ; +arg_list = '*' (* only valid for count(*) *) + | [ Expr , { ',' , Expr } ] ; +ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; +ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; +ObjectMember = ( identifier | string_literal ) , ':' , Expr ; +Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; +Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; +Binding = identifier , ':' , Expr ; +Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; +Variable = variable ; +``` + +**Disambiguation:** The `/` character introduces a regular expression when it appears at the +start of input, or when the preceding token is an operator or opening delimiter — specifically +one of: `and`, `or`, `not`, `(`, `[`, `,`, `=`, `<>`, `like`, `>`, `>=`, `<`, `<=`, `in`, +`is`, `&&`, `||`, `!=`, `==`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is +the division operator. ### Queries +```ebnf +Query = [ ExplainClause ] + SelectClause + [ IntoClause ] + [ FromClause ] + [ WhereClause ] + [ GroupByClause ] + [ HavingClause ] + [ OrderByClause ] + [ LimitClause ] + [ ForClause ] ; +ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; +SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; +SelectColumn = '*' + | Expr , [ 'as' , identifier ] ; +IntoClause = 'into' , variable ; +FromClause = 'from' , source , { LateralJoin } ; +source = 'stream' | 'series' ; +LateralJoin = 'lateral' , Expr , 'as' , identifier ; +WhereClause = 'where' , Expr ; +GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; +Grouping = TimeGrouping + | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; +TimeGrouping = 'time' , '(' , duration , ')' ; +HavingClause = 'having' , Expr ; +OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; +Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; +LimitClause = 'limit' , natural ; +ForClause = 'for' , ForOption , { ',' , ForOption } ; +ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; +``` + +Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains +metric samples. + +## Search Expression Examples + +| Example | Effect | +|---|-----------------------------------------------------| +| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | +| `Has(@Start)` | Match all spans (excludes log events). | +| `@Message like '%overflow%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | ## Gotchas @@ -166,4 +299,6 @@ These built-in functions and operators work with individual values. See Aggregat - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use JavaScript's system of comparisons. - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. + - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. + - Regular expression evaluation is extremely expensive, avoid these as much as possible. \ No newline at end of file diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 90736448..123b7ea2 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -41,7 +41,7 @@ static void CopyFilesRecursive(string source, string destination) foreach (var file in Directory.EnumerateFiles(source)) { - File.Copy(file, Path.Combine(destination, Path.GetFileName(file))); + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); } foreach (var directory in Directory.EnumerateDirectories(source)) From 229bee3160619a19f0c1866eee04e78566c0a8c6 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 17:08:51 +1000 Subject: [PATCH 37/98] More skill and tool WIP --- .claude/skills/seq-search-and-query/SKILL.md | 23 +- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 54 +++- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 5 +- src/SeqCli/Mcp/McpSession.cs | 16 +- .../Tools/Search/SearchAndQueryToolType.cs | 273 ++++++++++++++---- .../Resources/seq-search-and-query/SKILL.md | 23 +- src/SeqCli/Syntax/DurationMoniker.cs | 2 +- test/SeqCli.Tests/Mcp/McpSessionTests.cs | 16 + 8 files changed, 310 insertions(+), 102 deletions(-) create mode 100644 test/SeqCli.Tests/Mcp/McpSessionTests.cs diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index 0b425248..79bea87a 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -6,7 +6,7 @@ metadata: author: Datalust and Contributors --- -Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to compute tabular, aggregate results from the same data. > This skill does not currently cover interactions with metrics (the `series` storage object). @@ -268,15 +268,17 @@ ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains metric samples. -## Search Expression Examples +## Expression Examples -| Example | Effect | -|---|-----------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | -| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | -| `Has(@Start)` | Match all spans (excludes log events). | +| Example | Effect | +|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | +| `Has(@Start)` | Match all spans (excludes log events). | | `@Message like '%overflow%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | ## Gotchas @@ -301,4 +303,7 @@ metric samples. - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. - Regular expression evaluation is extremely expensive, avoid these as much as possible. - \ No newline at end of file + - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). + - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. + - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. + - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index cf8ac5b4..eb720146 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Threading.Tasks; using Autofac.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; @@ -30,30 +31,55 @@ class RunCommand: Command { readonly ConnectionFeature _connection; readonly StoragePathFeature _storagePath; + bool _debug; public RunCommand() { _connection = Enable(); _storagePath = Enable(); + Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection.", + _ => _debug = true); } protected override async Task Run() { var config = RuntimeConfigurationLoader.Load(_storagePath); - - var builder = Host.CreateApplicationBuilder(); - builder.ConfigureContainer(new AutofacServiceProviderFactory()); - builder.Services.AddSerilog(); - builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); - builder.Services.AddSingleton(); - builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithTools([ - typeof(SearchAndQueryToolType) - ]); - - await builder.Build().RunAsync(); + + if (_debug) + { + Log.Logger = new LoggerConfiguration() + .Enrich.WithProperty("Application", "seqcli") + .WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector())) + .CreateLogger(); + + Log.Information("seqcli MCP server starting up"); + } + + try + { + var builder = Host.CreateApplicationBuilder(); + builder.ConfigureContainer(new AutofacServiceProviderFactory()); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); + builder.Services.AddSingleton(); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools([ + typeof(SearchAndQueryToolType) + ]); + + await builder.Build().RunAsync(); + } + catch (Exception ex) + { + Log.Fatal(ex, "Unhandled exception"); + return 1; + } + finally + { + await Log.CloseAndFlushAsync(); + } return 0; } } \ No newline at end of file diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 2e7c0266..f68d39c8 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -59,7 +59,7 @@ static uint ParseEventType(string dollarPrefixedHex) static string ReconstructTemplate(IEnumerable tokens) { - return string.Concat(tokens.Select(t => t.RawText)); + return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); } static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) @@ -99,7 +99,7 @@ static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(st output.Write('}'); } - static void WriteValue(TextWriter output, object? value) + public static void WriteValue(TextWriter output, object? value) { if (value == UndefinedValue) { @@ -147,6 +147,7 @@ or byte or ushort or uint or ulong or UInt128 or if (value is DateTime dt) { output.Write($"DateTime('{dt:O}')"); + return; } if (value is JArray ja) diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 0a1e6a60..099c0d36 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -31,14 +31,14 @@ public string ImportSearchResult(EventEntity evt) } } - static string FormatResultId(int resultId) + internal static string FormatResultId(int resultId) { - return "E" + resultId.ToString("X5"); + return "R" + resultId.ToString("X5"); } - static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) + internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) { - if (!formatted.StartsWith('E') || !int.TryParse(formatted.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) { resultId = null; return false; @@ -54,7 +54,7 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt { result = null; error = - "The result id is not correctly formatted; result ids are strings beginning with `E`, followed by a short character string."; + "The result id is not correctly formatted; result ids are strings beginning with `R`, followed by a short character string."; return false; } @@ -90,17 +90,17 @@ public IEnumerable EnumerateUserPropertyNames(CancellationToken cancella { cancellationToken.ThrowIfCancellationRequested(); - foreach (var property in evt.Properties) + foreach (var property in evt.Properties ?? []) { foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) yield return unique; } - foreach (var property in evt.Scope) + foreach (var property in evt.Scope ?? []) { foreach (var unique in EnumerateUnique(seen, "@Scope", false, property.Name, property.Value, 1)) yield return unique; } - foreach (var property in evt.Resource) + foreach (var property in evt.Resource ?? []) { foreach (var unique in EnumerateUnique(seen, "@Resource", false, property.Name, property.Value, 1)) yield return unique; diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 1be04f11..8f2f16fd 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -2,13 +2,19 @@ using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; +using System.Globalization; using System.IO; using System.Linq; +using System.Net.Http; +using System.Text; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using Newtonsoft.Json; using Seq.Api; +using Seq.Api.Client; +using Seq.Api.Model.Data; using Seq.Api.Model.Events; using Seq.Api.Model.Expressions; using Seq.Syntax.Templates; @@ -27,12 +33,19 @@ class SearchAndQueryToolType(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( - $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{Substring(ToString(@x), 0, 140)}}" + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}\n{{#end}}" ); + static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + Culture = CultureInfo.InvariantCulture, + FloatParseHandling = FloatParseHandling.Decimal, + }); + [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + - "a `result_id` of the form `E..` which is valid in the current MCP session. Individual events can be " + + "a `result_id` of the form `R#####` which is valid in the current MCP session. Individual events can be " + "viewed in full using the `seq_read_search_result` tool. Use the `seq-search-and-query` " + "skill when calling this tool.")] [return: Description("Search results and status information.")] @@ -42,27 +55,16 @@ public async Task SearchEventsAsync( int limit, [Description("A Seq search expression evaluated over event properties.")] string? predicate = null, - [Description("The search timeout, in seconds; the default is 45.")] - [Range(5, 180)] - int timeoutSeconds = 45, CancellationToken cancellationToken = default) { if (!string.IsNullOrWhiteSpace(predicate)) { - if (!predicate.Contains("@Timestamp") || predicate.Contains("@Id")) + if (!predicate.Contains("@Timestamp") && + !predicate.Contains("@Id") && + !predicate.Contains("@TraceId")) { - return new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = "The predicate doesn't adequately constrain the search range (by `@Timestamp` or `@Id`). " + - "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", - } - ] - }; + return SimpleTextResult("The predicate doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", isError: true); } ExpressionPart strict; @@ -90,24 +92,15 @@ public async Task SearchEventsAsync( } if (strict.MatchedAsText) { - return new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = $"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}" - } - ], - }; + return SimpleTextResult($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", + isError: true); } } var resultsLock = new Lock(); - Exception? error = null; + string? error = null; var results = new List(); - var timeout = Task.Delay(TimeSpan.FromSeconds(timeoutSeconds), cancellationToken); + var timeout = Task.Delay(TimeSpan.FromSeconds(45), cancellationToken); using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var cancelEnumerateToken = cancelEnumerate.Token; var enumerate = Task.Run(async () => @@ -117,6 +110,7 @@ public async Task SearchEventsAsync( await foreach (var evt in connection.Events.EnumerateAsync( filter: predicate, count: limit, + render: true, cancellationToken: cancelEnumerateToken)) { lock (resultsLock) @@ -134,7 +128,7 @@ public async Task SearchEventsAsync( lock (resultsLock) { - error = ex; + error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); } } @@ -144,7 +138,7 @@ public async Task SearchEventsAsync( await cancelEnumerate.CancelAsync(); EventEntity[] takenResults; - Exception? takenError; + string? takenError; lock (resultsLock) { takenResults = results.ToArray(); @@ -166,7 +160,7 @@ public async Task SearchEventsAsync( } else { - resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError.Message}"; + resultSetStatus = $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError}"; } } else if (completed) @@ -229,37 +223,18 @@ public Task ReadSearchResultJsonAsync( { if (!session.TryGetSearchResult(result_id, out var result, out var error)) { - return Task.FromResult(new CallToolResult - { - IsError = true, - Content = - [ - new TextContentBlock - { - Text = error - } - ] - }); + return Task.FromResult(SimpleTextResult(error, isError: true)); } var resultText = new StringWriter(); SeqSyntaxFormatter.FormatAsObjectLiteral(result, resultText); - return Task.FromResult(new CallToolResult - { - Content = - [ - new TextContentBlock - { - Text = resultText.ToString() - } - ] - }); + return Task.FromResult(SimpleTextResult(resultText.ToString())); } - [McpServerTool(Name = "seq_inspect_schema", ReadOnly = true, Title = "Inspect Event Schema")] + [McpServerTool(Name = "seq_inspect_result_schema", ReadOnly = true, Title = "Inspect Search Result Schema")] [Description("List the user-defined top-level, scope, and resource property names observed on events " + - "so far in this session. Only events retrieved in search results are considered.")] + "in search results so far in this session. Only events retrieved in search results are considered.")] [return: Description("A list containing Seq syntax-formatted property names.")] public Task InspectSchemaAsync(CancellationToken cancellationToken) { @@ -274,15 +249,195 @@ public async Task QueryAsync( [Description("A Seq query language query.")] string query, CancellationToken cancellationToken) + { + if (!query.Contains("@Timestamp") && + !query.Contains("@Id") && + !query.Contains("@TraceId")) + { + return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); + } + + QueryResultPart result; + try + { + var request = new HttpRequestMessage + { + RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query)), + Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") + }; + var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); + result = Serializer.Deserialize( + new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; + } + catch (Exception ex) + { + if (ex.GetBaseException() is not OperationCanceledException) + { + Log.Error(ex, "Exception thrown during query execution"); + } + + var error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); + return SimpleTextResult($"The search failed. {error}", isError: true); + } + + if (result.Error != null) + { + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = $"The query could not be executed. {result.Error}" + }, + new TextContentBlock + { + Text = string.Join(" ", result.Reasons) + }, + new TextContentBlock + { + Text = result.Suggestion != null ? $"Did you mean: {result.Suggestion}?" : "" + } + ] + }; + } + + var output = new StringWriter(); + var first = true; + FlattenResult(result, row => + { + if (first) + { + first = false; + var firstCol = true; + foreach (var heading in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + output.Write(heading); + } + output.WriteLine(); + output.WriteLine(); + } + else + { + var firstCol = true; + foreach (var value in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + SeqSyntaxFormatter.WriteValue(output, value); + } + output.WriteLine(); + } + }); + + return new CallToolResult + { + Content = + [ + new TextContentBlock + { + Text = output.ToString() + } + ] + }; + } + + static void FlattenResult(QueryResultPart result, Action> writeRow) + { + if (result.Error != null) + return; + + if (result.Rows != null) + { + writeRow(result.Columns!); + foreach (var row in result.Rows) + { + writeRow(row); + } + } + else if (result.Slices != null) + { + writeRow(new object[] {"time"}.Concat(result.Columns!)); + + var empty = result.Columns!.Select(_ => "").ToArray(); + foreach (var slice in result.Slices) + { + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); + } + if (!any) + { + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); + } + } + } + else if (result.Series != null) + { + writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); + foreach (var series in result.Series) + { + foreach (var slice in series.Slices) + { + var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); + } + if (!any) + { + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); + } + } + } + } + else + { + throw new NotImplementedException("Query result set does not conform to any expected pattern."); + } + } + + static IEnumerable MergeColumns(IReadOnlyList columns, TimeseriesPart? firstSeries) + { + if (firstSeries == null) + yield break; + + var i = 0; + for (; i < firstSeries.Key.Length; ++i) + { + yield return columns[i]; + } + + yield return "time"; + + for (; i < columns.Count; ++i) + { + yield return columns[i]; + } + } + + static CallToolResult SimpleTextResult(string resultText, bool isError = false) { return new CallToolResult { - IsError = true, + IsError = isError, Content = [ new TextContentBlock { - Text = "The query tool is not implemented." + Text = resultText } ] }; diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 0b425248..79bea87a 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -6,7 +6,7 @@ metadata: author: Datalust and Contributors --- -Seq is a database containing log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to +Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to compute tabular, aggregate results from the same data. > This skill does not currently cover interactions with metrics (the `series` storage object). @@ -268,15 +268,17 @@ ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains metric samples. -## Search Expression Examples +## Expression Examples -| Example | Effect | -|---|-----------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | -| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | -| `Has(@Start)` | Match all spans (excludes log events). | +| Example | Effect | +|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | +| `Has(@Start)` | Match all spans (excludes log events). | | `@Message like '%overflow%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | ## Gotchas @@ -301,4 +303,7 @@ metric samples. - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. - Regular expression evaluation is extremely expensive, avoid these as much as possible. - \ No newline at end of file + - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). + - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. + - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. + - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. diff --git a/src/SeqCli/Syntax/DurationMoniker.cs b/src/SeqCli/Syntax/DurationMoniker.cs index a579ff8d..0bc3d028 100644 --- a/src/SeqCli/Syntax/DurationMoniker.cs +++ b/src/SeqCli/Syntax/DurationMoniker.cs @@ -31,7 +31,7 @@ public static string FromTimeSpan(TimeSpan timeSpan) Component(timeSpan.TotalMinutes, "m"), Component(timeSpan.TotalSeconds, "s"), Component(timeSpan.TotalMilliseconds, "ms") - }.First(c => c != ""); + }.FirstOrDefault(c => c != "", $"{timeSpan.TotalMilliseconds}ms"); } static string Component(double value, string moniker) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs new file mode 100644 index 00000000..b5106f3e --- /dev/null +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -0,0 +1,16 @@ +using SeqCli.Mcp; +using Xunit; + +namespace SeqCli.Tests.Mcp; + +public class McpSessionTests +{ + [Fact] + public void ResultIdsRoundTrip() + { + const int id = 1245; + var formatted = McpSession.FormatResultId(id); + Assert.True(McpSession.TryParseResultId(formatted, out var rt)); + Assert.Equal(id, rt); + } +} \ No newline at end of file From f22d041742b6c972cc1301fa66b5da87f39a833a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 17:15:10 +1000 Subject: [PATCH 38/98] More fixes --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 4 ++-- src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs | 1 + src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index eb720146..8d9b073c 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -48,11 +48,11 @@ protected override async Task Run() if (_debug) { Log.Logger = new LoggerConfiguration() - .Enrich.WithProperty("Application", "seqcli") + .Enrich.WithProperty("Application", "seqcli mcp run") .WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector())) .CreateLogger(); - Log.Information("seqcli MCP server starting up"); + Log.Information("Seq MCP server starting up"); } try diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index f68d39c8..78cc730a 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -169,6 +169,7 @@ or byte or ushort or uint or ulong or UInt128 or if (value is JObject jo) { WriteObject(output, false, jo.Properties().Select(p => (p.Name, (object?)p.Value))); + return; } if (value is JValue jt) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 8f2f16fd..c0e3acf9 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -33,7 +33,7 @@ class SearchAndQueryToolType(McpSession session, SeqConnection connection) { const string ResultIdPropertyName = "__seqcli_ResultId"; static readonly ExpressionTemplate SearchResultFormatter = new ( - $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}\n{{#end}}" + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...\n{{#end}}" ); static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings @@ -263,7 +263,7 @@ public async Task QueryAsync( { var request = new HttpRequestMessage { - RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query)), + RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") }; var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); From 9cb90ee429f1f04035fab4bdb56040045bb950fe Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 17:26:36 +1000 Subject: [PATCH 39/98] More WIP --- .claude/skills/seq-search-and-query/SKILL.md | 4 ++++ .../Mcp/Tools/Search/SearchAndQueryToolType.cs | 12 ++++++------ .../Skills/Resources/seq-search-and-query/SKILL.md | 4 ++++ 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index 79bea87a..de28d032 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -307,3 +307,7 @@ metric samples. - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. + - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to + conserve resources when speculatively exploring. + - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, + you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index c0e3acf9..29f6b353 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -59,9 +59,9 @@ public async Task SearchEventsAsync( { if (!string.IsNullOrWhiteSpace(predicate)) { - if (!predicate.Contains("@Timestamp") && - !predicate.Contains("@Id") && - !predicate.Contains("@TraceId")) + if (!predicate.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + !predicate.Contains("@Id", StringComparison.OrdinalIgnoreCase) && + !predicate.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) { return SimpleTextResult("The predicate doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 1d`.", isError: true); @@ -250,9 +250,9 @@ public async Task QueryAsync( string query, CancellationToken cancellationToken) { - if (!query.Contains("@Timestamp") && - !query.Contains("@Id") && - !query.Contains("@TraceId")) + if (!query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + !query.Contains("@Id", StringComparison.OrdinalIgnoreCase) && + !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) { return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 79bea87a..de28d032 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -307,3 +307,7 @@ metric samples. - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. + - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to + conserve resources when speculatively exploring. + - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, + you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. From 1b47da13abe52e62e1ceb54a6d4b349cc7e74b2a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 29 May 2026 18:01:59 +1000 Subject: [PATCH 40/98] Minor tweaks --- .claude/skills/seq-search-and-query/SKILL.md | 2 +- src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs | 6 ++++-- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md index de28d032..4d4004c6 100644 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ b/.claude/skills/seq-search-and-query/SKILL.md @@ -306,8 +306,8 @@ metric samples. - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to conserve resources when speculatively exploring. - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. + - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 29f6b353..f2cd4dd2 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -250,9 +250,11 @@ public async Task QueryAsync( string query, CancellationToken cancellationToken) { - if (!query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + if (query.Contains("from", StringComparison.OrdinalIgnoreCase) && + (!query.Contains("where", StringComparison.OrdinalIgnoreCase) || + !query.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && !query.Contains("@Id", StringComparison.OrdinalIgnoreCase) && - !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) + !query.Contains("@TraceId", StringComparison.OrdinalIgnoreCase))) { return SimpleTextResult("The query doesn't adequately constrain the search range (by `@Timestamp`, `@TraceId`, or `@Id`). " + "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index de28d032..4d4004c6 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -306,8 +306,8 @@ metric samples. - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - - Group keys are automatically included in result rowsets and shouldn't be explicitly included in the `select` list. - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to conserve resources when speculatively exploring. - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. + - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. From aaaeb41fa5b016027751a098a58f4422ebc512e2 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 13:53:10 +1000 Subject: [PATCH 41/98] Remove .claude and add to .gitignore --- .claude/skills/seq-search-and-query/SKILL.md | 313 ------------------- .gitignore | 4 + 2 files changed, 4 insertions(+), 313 deletions(-) delete mode 100644 .claude/skills/seq-search-and-query/SKILL.md diff --git a/.claude/skills/seq-search-and-query/SKILL.md b/.claude/skills/seq-search-and-query/SKILL.md deleted file mode 100644 index 4d4004c6..00000000 --- a/.claude/skills/seq-search-and-query/SKILL.md +++ /dev/null @@ -1,313 +0,0 @@ ---- -name: seq-search-and-query -description: Search and query logs and spans in Seq. Use when interacting with Seq. -license: Apache-2.0 -metadata: - author: Datalust and Contributors ---- - -Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to -compute tabular, aggregate results from the same data. - -> This skill does not currently cover interactions with metrics (the `series` storage object). - -## Data Model - -All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the -`@Start` property. The following built-in properties are supported. - -| Built in property name | Type | Description | -|------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | -| `@Data` | `object` | A compact internal representation of the event as a single structured object. | -| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | -| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | -| `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | -| `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | -| `@Id` | `string` | The event's unique id in Seq. | -| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'error'`, and `'err'` would all be typical values. | -| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | -| `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | -| `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | -| `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | -| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | -| `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | -| `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | -| `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | -| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | -| `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | -| `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | - -## Type System - -Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. - -| Type name | Description | Example literals | -|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| -| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | -| **boolean** | The atoms `true` and `false`. | `true`, `false` | -| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | -| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | -| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | - -In expression evaluation, Seq does not perform any type coercion. - -The results of functions and operators that receive invalid arguments are undefined, which is the absence of a value -(_undefined_ has roughly the same "poison" semantics as `NULL` in standard SQL). - -Type notation in this document column uses the suffix `?` on a type name to indicate values that may be undefined. -The synthetic type name `any` is used as an alias for `null | boolean | number | array | object`. - -## Scalar Functions and Operators - -These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. - -| Function signature | Description | -|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | -| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | -| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | -| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | -| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | -| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | -| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | -| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | -| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | -| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | -| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | -| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | -| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | -| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | -| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | -| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | -| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | -| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | -| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | -| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | -| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | -| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | -| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | -| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | -| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | -| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | -| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `ToNumber(str: string): number?` | Parse string `str` as a number. | -| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | -| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | -| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | -| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | -| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | -| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | -| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | - -## Aggregate Functions - -`select` queries (see grammar below) have access to the following aggregate functions. - -| Aggregate function signature | Description | Example | -|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| -| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | -| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | -| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | -| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | -| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | -| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | -| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | -| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | -| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | -| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | -| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | -| `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | -| `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | -| `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | - -## Grammar - -### Base - -```ebnf -identifier = ( letter | '_' ) , { letter | digit | '_' } ; -built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; -variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; -letter = ? any Unicode letter ? ; -digit = ? any Unicode digit ? ; -string_literal = "'" , { string_char } , "'" ; -string_char = "''" | ? any character except single quote ? ; -number = natural , [ '.' , natural ] ; -hex_number = '0x' , hex_digit , { hex_digit } ; -natural = digit , { digit } ; -hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' - | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; -duration = { natural , time_unit }- ; -time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; -regular_expression = '/' , { regex_char } , '/' ; -regex_char = '\/' | ? any character except '/' ? ; -``` - -### Expression - -``` -Expr = Disjunction ; -Disjunction = Conjunction , { 'or' , Conjunction } ; -Conjunction = Comparison , { 'and' , Comparison } ; -Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; -comparison_op = 'not' , 'like' - | 'like' - | 'not' , 'in' - | 'in' - | '<=' | '<>' | '<' - | '>=' | '>' - | '=' ; -Comparand = Term , { ( '+' | '-' ) , Term } ; -Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; -InnerTerm = Operand , { '^' , Operand } ; - -Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; -unary_op = '-' | 'not' ; -null_test = 'null' | 'not' , 'null' ; -Path = Factor , { path_step } ; -path_step = '.' , identifier - | '[' , ( wildcard | Expr ) , ']' ; -wildcard = '?' | '*' ; -Factor = '(' , Expr , ')' - | Item ; -Item = Property - | Literal - | Function - | ArrayLiteral - | ObjectLiteral - | Conditional - | Block - | Lambda - | Variable ; -Property = built_in_identifier - | identifier ; (* when not followed by '(' *) -Literal = string_literal - | number - | hex_number - | duration - | regular_expression - | 'true' - | 'false' - | 'null' ; -Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; -function_name = identifier - | 'and' | 'not' | 'or' ; -arg_list = '*' (* only valid for count(*) *) - | [ Expr , { ',' , Expr } ] ; -ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; -ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; -ObjectMember = ( identifier | string_literal ) , ':' , Expr ; -Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; -Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; -Binding = identifier , ':' , Expr ; -Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; -Variable = variable ; -``` - -**Disambiguation:** The `/` character introduces a regular expression when it appears at the -start of input, or when the preceding token is an operator or opening delimiter — specifically -one of: `and`, `or`, `not`, `(`, `[`, `,`, `=`, `<>`, `like`, `>`, `>=`, `<`, `<=`, `in`, -`is`, `&&`, `||`, `!=`, `==`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is -the division operator. - -### Queries - -```ebnf -Query = [ ExplainClause ] - SelectClause - [ IntoClause ] - [ FromClause ] - [ WhereClause ] - [ GroupByClause ] - [ HavingClause ] - [ OrderByClause ] - [ LimitClause ] - [ ForClause ] ; -ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; -SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; -SelectColumn = '*' - | Expr , [ 'as' , identifier ] ; -IntoClause = 'into' , variable ; -FromClause = 'from' , source , { LateralJoin } ; -source = 'stream' | 'series' ; -LateralJoin = 'lateral' , Expr , 'as' , identifier ; -WhereClause = 'where' , Expr ; -GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; -Grouping = TimeGrouping - | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; -TimeGrouping = 'time' , '(' , duration , ')' ; -HavingClause = 'having' , Expr ; -OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; -Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; -LimitClause = 'limit' , natural ; -ForClause = 'for' , ForOption , { ',' , ForOption } ; -ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; -``` - -Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains -metric samples. - -## Expression Examples - -| Example | Effect | -|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | -| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | -| `Has(@Start)` | Match all spans (excludes log events). | -| `@Message like '%overflow%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | -| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | -| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | - -## Gotchas - - - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values. - - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar - and built-ins described above. - - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from - **most recent** to least recent. - - Data in Seq servers don't always use OpenTelemetry semantic conventions. When searching or querying, only use property - names from the built-ins described above, that appear on search results, or that are returned from the search result - schema tool. - - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names - to be used. - - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an - embedded literal single quote. Backslash escaping is not recognized. - - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (`ulong` with 100 ns - resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will - fail: use duration literals for durations, and the `DateTime` function - to convert from ISO-8601 strings. - - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use - JavaScript's system of comparisons. - - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. - - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. - - Regular expression evaluation is extremely expensive, avoid these as much as possible. - - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). - - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to - conserve resources when speculatively exploring. - - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, - you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. - - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. diff --git a/.gitignore b/.gitignore index fcfa8504..4e050436 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,7 @@ __pycache__/ global.json .DS_Store/ + +.claude/ +.qwen/ +.agents/ From 2229605a21d7494c3df524e6dfb87037e2d8768b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 14:29:42 +1000 Subject: [PATCH 42/98] Minimal tests for identifier generation --- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 122 +++++++++--------- src/SeqCli/Mcp/McpSession.cs | 47 ++----- src/SeqCli/Mcp/Schema/EventEntitySchema.cs | 47 +++++++ .../Tools/Search/SearchAndQueryToolType.cs | 2 +- .../Mcp/SeqSyntaxFormatterTests.cs | 20 +++ 5 files changed, 142 insertions(+), 96 deletions(-) create mode 100644 src/SeqCli/Mcp/Schema/EventEntitySchema.cs create mode 100644 test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 78cc730a..fc288617 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -11,13 +11,15 @@ namespace SeqCli.Mcp.Formatting; -// Constructs Seq syntax literals from API events. This provides a language model client with strong cues as -// to how the properties of an event should be incorporated into future queries/expressions. +/// +/// Constructs Seq syntax literals from API events. This provides a language model client with strong cues as +/// to how the properties of an event should be incorporated into future queries/expressions. +/// static partial class SeqSyntaxFormatter { static readonly object UndefinedValue = new(); - [GeneratedRegex("[_a-zA-Z][_a-zA-Z0-9]*")] + [GeneratedRegex("^[_a-zA-Z][_a-zA-Z0-9]*$")] private static partial Regex IdentifierRegex(); static readonly HashSet Keywords = new(StringComparer.OrdinalIgnoreCase) @@ -26,8 +28,22 @@ static partial class SeqSyntaxFormatter "analyze", "as", "asc", "by", "desc", "explain", "for", "from", "group", "having", "into", "lateral", "limit", "lower", "order", "select", "where" }; + + public static string MakeIdentifier(string prefixPath, string propertyName, bool prefixIsOptional) + { + if (IdentifierRegex().IsMatch(propertyName)) + { + if (prefixIsOptional && !Keywords.Contains(propertyName)) + return propertyName; + return $"{prefixPath}.{propertyName}"; + } + + var sw = new StringWriter(); + WriteValue(sw, propertyName); + return $"{prefixPath}[{sw}]"; + } - public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) + public static void WriteEvent(TextWriter output, EventEntity evt) { WriteObject( output, @@ -52,53 +68,6 @@ public static void FormatAsObjectLiteral(EventEntity evt, TextWriter output) ); } - static uint ParseEventType(string dollarPrefixedHex) - { - return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); - } - - static string ReconstructTemplate(IEnumerable tokens) - { - return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); - } - - static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) - { - output.Write('{'); - var first = true; - foreach (var (name, value) in members) - { - if (value == UndefinedValue) - continue; - - if (first) - first = false; - else - output.Write(", "); - - if (topLevel) - { - output.Write(name); - } - else - { - WriteMemberName(output, name); - } - - output.Write(": "); - - if (value is Action valueWriter) - { - valueWriter(output); - } - else - { - WriteValue(output, value); - } - } - output.Write('}'); - } - public static void WriteValue(TextWriter output, object? value) { if (value == UndefinedValue) @@ -198,17 +167,50 @@ static void WritePropertiesObject(TextWriter output, List mem WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); } - public static string MakeIdentifier(string prefixPath, bool optionalPrefix, string propertyName) + static uint ParseEventType(string dollarPrefixedHex) { - if (IdentifierRegex().IsMatch(propertyName)) + return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); + } + + static string ReconstructTemplate(IEnumerable tokens) + { + return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); + } + + static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) + { + output.Write('{'); + var first = true; + foreach (var (name, value) in members) { - if (optionalPrefix && !Keywords.Contains(propertyName)) - return propertyName; - return $"{prefixPath}.{propertyName}"; - } + if (value == UndefinedValue) + continue; - var sw = new StringWriter(); - WriteValue(sw, propertyName); - return $"{prefixPath}[{sw}]"; + if (first) + first = false; + else + output.Write(", "); + + if (topLevel) + { + output.Write(name); + } + else + { + WriteMemberName(output, name); + } + + output.Write(": "); + + if (value is Action valueWriter) + { + valueWriter(output); + } + else + { + WriteValue(output, value); + } + } + output.Write('}'); } } diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 099c0d36..41681e2f 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -5,8 +5,7 @@ using Seq.Api.Model.Events; using System; using System.Linq; -using Newtonsoft.Json.Linq; -using SeqCli.Mcp.Formatting; +using SeqCli.Mcp.Schema; namespace SeqCli.Mcp; @@ -16,7 +15,7 @@ class McpSession int _nextId = 1; readonly Dictionary _resultIdToEventId = new(); readonly Dictionary _eventIdToResult = new(); - + public string ImportSearchResult(EventEntity evt) { lock (_sync) @@ -38,7 +37,8 @@ internal static string FormatResultId(int resultId) internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) { - if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) + if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var parsed)) { resultId = null; return false; @@ -48,7 +48,8 @@ internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out return true; } - public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, [NotNullWhen(false)] out string? error) + public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, + [NotNullWhen(false)] out string? error) { if (!TryParseResultId(resultId, out var parsed)) { @@ -57,7 +58,7 @@ public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEnt "The result id is not correctly formatted; result ids are strings beginning with `R`, followed by a short character string."; return false; } - + lock (_sync) { if (!_resultIdToEventId.TryGetValue(parsed.Value, out var eventId)) @@ -89,37 +90,13 @@ public IEnumerable EnumerateUserPropertyNames(CancellationToken cancella foreach (var evt in all) { cancellationToken.ThrowIfCancellationRequested(); - - foreach (var property in evt.Properties ?? []) - { - foreach (var unique in EnumerateUnique(seen, "@Properties", true, property.Name, property.Value, 1)) - yield return unique; - } - foreach (var property in evt.Scope ?? []) - { - foreach (var unique in EnumerateUnique(seen, "@Scope", false, property.Name, property.Value, 1)) - yield return unique; - } - foreach (var property in evt.Resource ?? []) - { - foreach (var unique in EnumerateUnique(seen, "@Resource", false, property.Name, property.Value, 1)) - yield return unique; - } - } - } - static IEnumerable EnumerateUnique(HashSet seen, string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) - { - var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, optionalPrefix, propertyName); - if (seen.Add(name)) - yield return name; - - if (depth < 5 && propertyValue is JObject jo) - { - foreach (var child in jo.Properties()) + foreach (var accessor in EventEntitySchema.EnumeratePropertyAccessorPaths(evt)) { - foreach (var childName in EnumerateUnique(seen, name, false, child.Name, child.Value, depth + 1)) - yield return childName; + if (seen.Add(accessor)) + { + yield return accessor; + } } } } diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs new file mode 100644 index 00000000..80312e00 --- /dev/null +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using SeqCli.Mcp.Formatting; + +namespace SeqCli.Mcp.Schema; + +static class EventEntitySchema +{ + const int MaxAccessorPathDepth = 5; + + public static IEnumerable EnumeratePropertyAccessorPaths(EventEntity evt) + { + foreach (var property in evt.Properties ?? []) + { + foreach (var accessor in EnumerateAccessorPaths("@Properties", true, property.Name, property.Value, 1)) + yield return accessor; + } + + foreach (var property in evt.Scope ?? []) + { + foreach (var accessor in EnumerateAccessorPaths("@Scope", false, property.Name, property.Value, 1)) + yield return accessor; + } + + foreach (var property in evt.Resource ?? []) + { + foreach (var accessor in EnumerateAccessorPaths("@Resource", false, property.Name, property.Value, 1)) + yield return accessor; + } + } + + static IEnumerable EnumerateAccessorPaths(string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) + { + var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, propertyName, optionalPrefix); + yield return name; + + if (depth < MaxAccessorPathDepth && propertyValue is JObject jo) + { + foreach (var child in jo.Properties()) + { + foreach (var childName in EnumerateAccessorPaths(name, false, child.Name, child.Value, depth + 1)) + yield return childName; + } + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index f2cd4dd2..acfeff03 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -227,7 +227,7 @@ public Task ReadSearchResultJsonAsync( } var resultText = new StringWriter(); - SeqSyntaxFormatter.FormatAsObjectLiteral(result, resultText); + SeqSyntaxFormatter.WriteEvent(resultText, result); return Task.FromResult(SimpleTextResult(resultText.ToString())); } diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs new file mode 100644 index 00000000..fccde0e3 --- /dev/null +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -0,0 +1,20 @@ +using SeqCli.Mcp.Formatting; +using Xunit; + +namespace SeqCli.Tests.Mcp; + +public class SeqSyntaxFormatterTests +{ + [Theory] + [InlineData("@Properties", "a", true, "a")] + [InlineData("@Properties", "a b", true, "@Properties['a b']")] + [InlineData("@Properties", "and", true, "@Properties.and")] + [InlineData("@Resource", "a", false, "@Resource.a")] + [InlineData("@Resource", "a b", false, "@Resource['a b']")] + [InlineData("@Resource", "and", false, "@Resource.and")] + public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, bool prefixIsOptional, string expected) + { + var actual = SeqSyntaxFormatter.MakeIdentifier(prefix, name, prefixIsOptional); + Assert.Equal(expected, actual); + } +} \ No newline at end of file From 76a6cece4aa7c7945730673b3b35ace94fa65d10 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 14:42:16 +1000 Subject: [PATCH 43/98] OTel property name accessor gotcha --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 4d4004c6..d9353c48 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -279,6 +279,7 @@ metric samples. | `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | | `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | | `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | +| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | ## Gotchas @@ -311,3 +312,5 @@ metric samples. - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. + - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and + `http.response.status_code` are written exactly like this. From c19d8398a4eed3fde98ec1546d931eeda29d7327 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 14:53:31 +1000 Subject: [PATCH 44/98] More gotchas; these are a useful catalog of papercuts we might actually want to tackle on the Seq side --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index d9353c48..0d85fffd 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -255,11 +255,13 @@ LateralJoin = 'lateral' , Expr , 'as' , identifier ; WhereClause = 'where' , Expr ; GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; Grouping = TimeGrouping - | Expr , [ 'ci' ] , [ 'as' , identifier ] , [ 'ci' ] ; + | Expr , [ 'ci' ] , [ 'as' , identifier ] ; TimeGrouping = 'time' , '(' , duration , ')' ; HavingClause = 'having' , Expr ; OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; -Ordering = Expr , [ 'ci' ] , [ 'asc' | 'desc' ] , [ 'ci' ] ; +Ordering = TimeOrdering + | Expr , [ 'ci' ] , [ 'asc' | 'desc' ] ; +TimeOrdering = 'time' ; LimitClause = 'limit' , natural ; ForClause = 'for' , ForOption , { ',' , ForOption } ; ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; @@ -314,3 +316,5 @@ metric samples. - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and `http.response.status_code` are written exactly like this. + - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't + re-specified. From 9f131b901adc8717201c09a2eb08f94107159bde Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 17:31:46 +1000 Subject: [PATCH 45/98] Get the (implemented) integration tests running. Assisted-by: Claude 4.8 Opus --- .../Mcp/McpSessionBasicsTestCase.cs | 29 +++++++++++++++++++ test/SeqCli.EndToEnd/Program.cs | 6 ++++ .../Skills/SkillsInstallTestCase.cs | 17 +++-------- .../SeqCli.EndToEnd/Support/CaptiveProcess.cs | 7 +++-- .../Support/CliCommandRunner.cs | 8 ++--- .../Support/TestConfiguration.cs | 6 ++-- 6 files changed, 51 insertions(+), 22 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs new file mode 100644 index 00000000..580a7899 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; + +namespace SeqCli.EndToEnd.Mcp; + +public class McpSessionBasicsTestCase: ICliTestCase +{ + public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + // Log a handful of simple informational events through the logger. It's worth going to some effort to add + // some nested structured properties. + + // Configure an `McpClient` connected to `seqcli mcp run` pointing to the shared Seq connection. + + // Call the search tool and verify that the events are (conditionally) found + + // Call the result inspection tool and pull back each event using the ids returned in the search results, + // ensuring they're what we expect. + + // Call the schema tool and check that all of the property paths from all of the events are present. + + // Call the query tool and compute an aggregate over the events, making sure we get the expected result set. + + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Program.cs b/test/SeqCli.EndToEnd/Program.cs index 35a392c9..71a02c7e 100644 --- a/test/SeqCli.EndToEnd/Program.cs +++ b/test/SeqCli.EndToEnd/Program.cs @@ -5,6 +5,12 @@ using Serilog; using Serilog.Debugging; +// Test cases reference data files using paths relative to the working directory (e.g. `Data/log-*.txt`), +// which are copied alongside the test binary. Anchor the working directory to the build output so the +// suite behaves identically however it's launched - `dotnet run --project ...` from the repo root, or +// `cd`-ing into the test directory first as CI does. +Environment.CurrentDirectory = AppContext.BaseDirectory; + Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateLogger(); diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 00546bbf..831ad5b9 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using System.Threading.Tasks; using Seq.Api; @@ -13,18 +12,10 @@ public class SkillsInstallTestCase : ICliTestCase public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) { using var tmp = new TestDataFolder(); - var previous = Environment.CurrentDirectory; - Environment.CurrentDirectory = tmp.Path; - try - { - var exit = runner.Exec("skills install -a test-agent"); - Assert.Equal(0, exit); - Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); - } - finally - { - Environment.CurrentDirectory = previous; - } + + var exit = runner.Exec("skills install -a test-agent", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, exit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs index c9b6ed1d..726da5e6 100644 --- a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs +++ b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs @@ -25,7 +25,8 @@ public CaptiveProcess( bool captureOutput = true, bool supplyInput = false, string stopCommandFullExePath = null, - string stopCommandArgs = null) + string stopCommandArgs = null, + string workingDirectory = null) { ArgumentNullException.ThrowIfNull(fullExePath); _captureOutput = captureOutput; @@ -42,7 +43,9 @@ public CaptiveProcess( CreateNoWindow = true, ErrorDialog = false, FileName = fullExePath, - Arguments = args ?? "" + Arguments = args ?? "", + // An empty working directory means the child inherits the parent process's current directory. + WorkingDirectory = workingDirectory ?? "" }; if (environment != null) diff --git a/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs b/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs index 6061017e..d4e0dfeb 100644 --- a/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs +++ b/test/SeqCli.EndToEnd/Support/CliCommandRunner.cs @@ -17,15 +17,15 @@ public class CliCommandRunner(TestConfiguration configuration, TestDataFolder te public ITestProcess? LastRunProcess { get; private set; } - public int Exec(string command, string? args = null, bool disconnected = false, Dictionary? environment = null, TimeSpan? timeout = null) + public int Exec(string command, string? args = null, bool disconnected = false, Dictionary? environment = null, TimeSpan? timeout = null, string? workingDirectory = null) { - using var process = Spawn(command, args, disconnected, environment); + using var process = Spawn(command, args, disconnected, environment, workingDirectory); return process.WaitForExit(timeout ?? DefaultExecTimeout); } - public CaptiveProcess Spawn(string command, string? args = null, bool disconnected = false, Dictionary? environment = null) + public CaptiveProcess Spawn(string command, string? args = null, bool disconnected = false, Dictionary? environment = null, string? workingDirectory = null) { - var process = configuration.SpawnCliProcess(command, args, environment, skipServerArg: disconnected); + var process = configuration.SpawnCliProcess(command, args, environment, skipServerArg: disconnected, workingDirectory: workingDirectory); LastRunProcess = process; return process; } diff --git a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs index e3b35aa8..fff6c7af 100644 --- a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs +++ b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs @@ -31,15 +31,15 @@ public TestConfiguration(Args args) public bool IsMultiuser => _args.Multiuser(); - public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false) + public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false, string workingDirectory = null) { if (command == null) throw new ArgumentNullException(nameof(command)); var commandWithArgs = $"{command} {additionalArgs}"; if (!skipServerArg) commandWithArgs += $" --server=\"{ServerListenUrl}\""; - - return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput); + + return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput, workingDirectory: workingDirectory); } public CaptiveProcess SpawnServerProcess(string storagePath) From 8ca84d5c40091a7a72774f3d90009e7c4c5acff1 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 18:40:15 +1000 Subject: [PATCH 46/98] Added a simple MCP end-to-end test case. Assisted-by: Claude 4.8 Opus --- .../Mcp/McpSessionBasicsTestCase.cs | 86 +++++++++++++++---- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index 580a7899..b57ba006 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -1,29 +1,79 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; using Seq.Api; using SeqCli.EndToEnd.Support; using Serilog; +using Xunit; namespace SeqCli.EndToEnd.Mcp; -public class McpSessionBasicsTestCase: ICliTestCase +// ReSharper disable once UnusedType.Global +public partial class McpSessionBasicsTestCase : ICliTestCase { - public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) { - // Log a handful of simple informational events through the logger. It's worth going to some effort to add - // some nested structured properties. - - // Configure an `McpClient` connected to `seqcli mcp run` pointing to the shared Seq connection. - - // Call the search tool and verify that the events are (conditionally) found - - // Call the result inspection tool and pull back each event using the ids returned in the search results, - // ensuring they're what we expect. - - // Call the schema tool and check that all of the property paths from all of the events are present. - - // Call the query tool and compute an aggregate over the events, making sure we get the expected result set. - - throw new NotImplementedException(); + var runId = "mcp-" + Guid.NewGuid().ToString("n"); + + var orders = new[] + { + (Number: 1, Amount: 2, Customer: new { Name = "Alice", Tier = "gold", Address = new { City = "Sydney" } }), + (Number: 2, Amount: 1, Customer: new { Name = "Bob", Tier = "silver", Address = new { City = "Hobart" } }), + (Number: 3, Amount: 3, Customer: new { Name = "Carol", Tier = "gold", Address = new { City = "Perth" } }), + }; + + foreach (var order in orders) + { + logger.Information("Order {OrderNumber} in run {RunId} placed by {@Customer} for {Amount} unit(s)", + order.Number, runId, order.Customer, order.Amount); + } + + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "seqcli mcp run", + Command = "dotnet", + Arguments = [TestConfiguration.TestedBinary, "mcp", "run", $"--server={connection.Client.ServerUrl}"] + }); + + await using var client = await McpClient.CreateAsync(transport); + + var predicate = $"RunId = '{runId}' and Customer.Tier = 'gold' and @Timestamp >= Now() - 1d"; + var searchResult = AssertTextResult(await client.CallToolAsync( + "seq_search", + new Dictionary { ["limit"] = 10, ["predicate"] = predicate })); + var resultIds = ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); + Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length); + + var detailResult = AssertTextResult(await client.CallToolAsync( + "seq_read_search_result", + new Dictionary { ["result_id"] = resultIds[0] })); + Assert.Contains(runId, detailResult); + Assert.Contains("Name: 'Carol'", detailResult); + + var schemaResult = AssertTextResult(await client.CallToolAsync("seq_inspect_result_schema")); + foreach (var expectedPath in new[] + { "OrderNumber", "RunId", "Amount", "Customer", "Customer.Name", "Customer.Tier", "Customer.Address.City" }) + Assert.Contains(expectedPath, schemaResult); + + var query = $"select sum(Amount) as Total from stream where RunId = '{runId}' and @Timestamp >= Now() - 1d"; + var queryResult = AssertTextResult(await client.CallToolAsync( + "seq_query", + new Dictionary { ["query"] = query })); + Assert.Contains("Total", queryResult); + Assert.Contains("6", queryResult); + } + + static string AssertTextResult(CallToolResult callToolResult) + { + var text = string.Join("\n", callToolResult.Content.OfType().Select(c => c.Text)); + Assert.False(callToolResult.IsError ?? false, text); + return text; } -} \ No newline at end of file + + [GeneratedRegex("R[0-9a-zA-Z]+")] + private static partial Regex ResultIdRegex(); +} From ae29b25531da98fbf9db311cde879132cd492227 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sat, 30 May 2026 21:12:59 +1000 Subject: [PATCH 47/98] Add tests for SeqSyntaxFormatter. Assisted-by: Claude 4.8 Opus --- .../Mcp/Formatting/SeqSyntaxFormatter.cs | 2 +- src/SeqCli/Mcp/McpSession.cs | 2 +- .../Mcp/SeqSyntaxFormatterTests.cs | 162 +++++++++++++++++- 3 files changed, 163 insertions(+), 3 deletions(-) diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index fc288617..741ebed6 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -121,7 +121,7 @@ or byte or ushort or uint or ulong or UInt128 or if (value is JArray ja) { - var first = false; + var first = true; output.Write('['); foreach (var element in ja) { diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 41681e2f..26c75f7e 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -37,7 +37,7 @@ internal static string FormatResultId(int resultId) internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) { - if (!formatted.StartsWith('R') || !int.TryParse(formatted.Substring(1), NumberStyles.HexNumber, + if (!formatted.StartsWith('R') || !int.TryParse(formatted[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed)) { resultId = null; diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs index fccde0e3..c883b49c 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -1,3 +1,11 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; using SeqCli.Mcp.Formatting; using Xunit; @@ -17,4 +25,156 @@ public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, boo var actual = SeqSyntaxFormatter.MakeIdentifier(prefix, name, prefixIsOptional); Assert.Equal(expected, actual); } -} \ No newline at end of file + + [Theory] + [MemberData(nameof(BuiltInPropertyCases))] + public void BuiltInPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable BuiltInPropertyCases() => + [ + [MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], + [MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], + [MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], + [MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], + [ + MakeEvent(e => e.MessageTemplateTokens = + [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]), + "@MessageTemplate: 'User {UserId}'" + ], + [MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], + [MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], + [MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], + [MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], + [MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"], + [MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"], + [MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"], + [MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"], + [MakeEvent(e => e.Properties = MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], + [MakeEvent(e => e.Scope = MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], + [MakeEvent(e => e.Resource = MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], + [MakeEvent(e => e.Definitions = MakeProperties(("d", 1))), "@Definitions: {d: 1}"] + ]; + + [Theory] + [InlineData("@Exception")] + [InlineData("@Elapsed")] + [InlineData("@TraceId")] + [InlineData("@SpanId")] + [InlineData("@SpanKind")] + [InlineData("@Start")] + [InlineData("@ParentId")] + [InlineData("@Properties")] + [InlineData("@Scope")] + [InlineData("@Resource")] + [InlineData("@Definitions")] + public void OptionalPropertiesAreOmittedWhenAbsent(string token) + { + Assert.DoesNotContain(token, Render(MakeEvent())); + } + + [Fact] + public void EmptyPropertyCollectionIsOmitted() + { + Assert.DoesNotContain("@Properties", Render(MakeEvent(e => e.Properties = []))); + } + + [Fact] + public void EventFormatIsAnObjectLiteral() + { + Assert.Equal( + "{@Id: 'event-1', @Timestamp: DateTime('2024-01-01T00:00:00.0000000Z'), " + + "@Level: 'Information', @Message: 'Hello', @MessageTemplate: 'Hello', @EventType: 0}", + Render(MakeEvent())); + } + + [Theory] + [MemberData(nameof(BasicPropertyFormattingCases))] + public void BasicPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable BasicPropertyFormattingCases() => + [ + [MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], + [MakeEvent(e => e.Level = null), "@Level: 'Information'"], + [MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" + ], + [MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], + [MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], + [MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" + ], + [MakeEvent(e => e.Properties = MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], + [MakeEvent(e => e.Properties = MakeProperties(("n", 42))), "@Properties: {n: 42}"], + [MakeEvent(e => e.Properties = MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], + [MakeEvent(e => e.Properties = MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], + [MakeEvent(e => e.Properties = MakeProperties(("b", true))), "@Properties: {b: true}"], + [MakeEvent(e => e.Properties = MakeProperties(("z", null))), "@Properties: {z: null}"] + ]; + + [Theory] + [MemberData(nameof(NestedPropertyCases))] + public void NestedPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + { + Assert.Contains(expectedLiteral, Render(evt)); + } + + public static IEnumerable NestedPropertyCases() => + [ + [ + MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web" }))), + "@Resource: {service: {name: 'web'}}" + ], + [ + MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), + "@Resource: {service: {name: 'web', version: '1.0'}}" + ], + [ + MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), + "@Resource: {service: {namespace: {name: 'web'}}}" + ], + [ + MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), + "@Properties: {http: {request: {method: 'GET'}}}" + ], + [ + MakeEvent(e => e.Scope = MakeProperties(("db", new JObject { ["system"] = "postgres" }))), + "@Scope: {db: {system: 'postgres'}}" + ], + [ + MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["content-type"] = "json" }))), + "@Properties: {http: {'content-type': 'json'}}" + ], + [ + MakeEvent(e => e.Properties = MakeProperties(("tags", new JArray("a", "b")))), + "@Properties: {tags: ['a', 'b']}" + ] + ]; + + static EventEntity MakeEvent(Action? configure = null) + { + var evt = new EventEntity + { + Id = "event-1", + Timestamp = "2024-01-01T00:00:00.0000000Z", + RenderedMessage = "Hello", + MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello" }], + EventType = "$00000000", + }; + configure?.Invoke(evt); + return evt; + } + + static List MakeProperties(params (string Name, object? Value)[] items) => + items.Select(i => new EventPropertyPart(i.Name, i.Value)).ToList(); + + static string Render(EventEntity evt) + { + var output = new StringWriter(); + SeqSyntaxFormatter.WriteEvent(output, evt); + return output.ToString(); + } +} From 093bccd67c9e5ee9f56e1a87d10248707affba35 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 08:53:32 +1000 Subject: [PATCH 48/98] A first cut `mcp install` command. Assisted-by: Claude 4.8 Opus --- src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs | 52 +++++++++ src/SeqCli/Mcp/McpServerInstaller.cs | 107 ++++++++++++++++++ .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 56 +++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs create mode 100644 src/SeqCli/Mcp/McpServerInstaller.cs create mode 100644 test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs diff --git a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs new file mode 100644 index 00000000..d847e277 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs @@ -0,0 +1,52 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading.Tasks; +using SeqCli.Mcp; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Mcp; + +[Command("mcp", "install", "Install or update the Seq MCP server for an agent", + Example = "seqcli mcp install --global --agent claude")] +class InstallCommand : Command +{ + bool _global; + string? _agent; + string? _profile; + + public InstallCommand() + { + Options.Add( + "g|global", + "Install for the current user globally; the default is to install into the current project directory", + _ => _global = true); + + Options.Add( + "a=|agent=", + "The agent name to install the MCP server for; the default is the generic name `agents`", + t => _agent = ArgumentString.Normalize(t)); + + Options.Add( + "profile=", + "A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used", + v => _profile = ArgumentString.Normalize(v)); + } + + protected override Task Run() + { + McpServerInstaller.Install(_agent?.ToLowerInvariant() ?? "agents", _global, _profile); + return Task.FromResult(0); + } +} diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs new file mode 100644 index 00000000..b1314a85 --- /dev/null +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -0,0 +1,107 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json.Linq; +using Serilog; + +namespace SeqCli.Mcp; + +static class McpServerInstaller +{ + const string ServerName = "seq"; + + // Agents whose MCP config location or shape diverges from the common + // `.{agent}/mcp.json` + `mcpServers` convention. Anything not listed here - + // including the default `agents` name and any unknown agent - uses the + // convention (see `Convention`), so adding support for a conformant agent + // requires no change at all, and a divergent one is a single entry here. + static readonly IReadOnlyDictionary KnownAgents = + new Dictionary + { + // Claude Code reads project servers from a root `.mcp.json`, and + // user-global servers from `~/.claude.json`. + ["claude"] = new( + global => global + ? Path.Combine(UserProfile, ".claude.json") + : Path.Combine(Environment.CurrentDirectory, ".mcp.json"), + "mcpServers"), + + // Windsurf keeps a single user-global config under `~/.codeium`. + ["windsurf"] = new( + global => global + ? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json") + : Path.Combine(Environment.CurrentDirectory, ".windsurf", "mcp.json"), + "mcpServers"), + + // VS Code nests servers under a `servers` key. Project config lives in + // `.vscode/mcp.json`; the user-global equivalent lives inside `settings.json`, + // which is a different merge target and isn't supported here yet. + ["vscode"] = new( + global => global + ? throw new NotSupportedException( + "VS Code stores user-level MCP servers in settings.json; install into a project with `seqcli mcp install --agent vscode` instead.") + : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), + "servers"), + }; + + public static void Install(string agent, bool global, string? profileName = null) + { + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); + var path = target.ResolvePath(global); + + // Merge into any existing config so other servers and unrelated settings survive. + var root = File.Exists(path) ? JObject.Parse(File.ReadAllText(path)) : new JObject(); + + if (root[target.ServerMapKey] is not JObject serverMap) + { + serverMap = new JObject(); + root[target.ServerMapKey] = serverMap; + } + + // A connection profile is the only connection setting we propagate; the server URL and + // API key are resolved from config at runtime so they're not baked into the agent's file. + var args = new JArray("mcp", "run"); + if (profileName != null) + { + args.Add("--profile"); + args.Add(profileName); + } + + serverMap[ServerName] = new JObject + { + ["command"] = "seqcli", + ["args"] = args, + }; + + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, root.ToString(Newtonsoft.Json.Formatting.Indented)); + + Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); + } + + static AgentTarget Convention(string agent) => + new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + $".{agent}", + "mcp.json"), + "mcpServers"); + + static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + sealed record AgentTarget(Func ResolvePath, string ServerMapKey); +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs new file mode 100644 index 00000000..6092edd0 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -0,0 +1,56 @@ +using System.IO; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +// ReSharper disable once UnusedType.Global +public class McpInstallTestCase : ICliTestCase +{ + public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + using var tmp = new TestDataFolder(); + + // Convention fallback: an agent that isn't specially known writes `.{agent}/mcp.json` + // with the common `mcpServers` shape pointing at a bare `mcp run`. + var exit = runner.Exec("mcp install -a test-agent", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, exit); + + var config = File.ReadAllText(Path.Combine(tmp.Path, ".test-agent/mcp.json")); + Assert.Contains("\"mcpServers\"", config); + Assert.Contains("\"seq\"", config); + Assert.Contains("\"seqcli\"", config); + + // Known-agent override: Claude Code reads a root `.mcp.json`, not `.claude/mcp.json`. + var claudeExit = runner.Exec("mcp install -a claude", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, claudeExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".mcp.json"))); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".claude/mcp.json"))); + + // Merge preserves any server already present rather than overwriting the file. + var mergePath = Path.Combine(tmp.Path, ".merge-agent/mcp.json"); + Directory.CreateDirectory(Path.GetDirectoryName(mergePath)!); + File.WriteAllText(mergePath, "{\"mcpServers\":{\"other\":{\"command\":\"x\"}}}"); + + var mergeExit = runner.Exec("mcp install -a merge-agent", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, mergeExit); + + var merged = File.ReadAllText(mergePath); + Assert.Contains("\"other\"", merged); + Assert.Contains("\"seq\"", merged); + + // A `--profile` is propagated onto the generated server's `mcp run` args; other + // connection settings (server URL, API key) are deliberately left to runtime config. + var profileExit = runner.Exec("mcp install -a profile-agent --profile Production", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, profileExit); + + var profileConfig = File.ReadAllText(Path.Combine(tmp.Path, ".profile-agent/mcp.json")); + Assert.Contains("\"--profile\"", profileConfig); + Assert.Contains("\"Production\"", profileConfig); + + return Task.CompletedTask; + } +} From c2eb030b908273d32d097e4425e4233d20f9238e Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 11:59:07 +1000 Subject: [PATCH 49/98] Tidy up. Assisted-by: Claude 4.8 Opus --- src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs | 2 +- .../Cli/Commands/Skills/InstallCommand.cs | 12 +----------- src/SeqCli/Mcp/McpServerInstaller.cs | 4 +++- src/SeqCli/Skills/SkillInstaller.cs | 19 +++++++++++++++---- .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 9 +++++++++ 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs index d847e277..8b1bed91 100644 --- a/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs @@ -46,7 +46,7 @@ public InstallCommand() protected override Task Run() { - McpServerInstaller.Install(_agent?.ToLowerInvariant() ?? "agents", _global, _profile); + McpServerInstaller.Install(_agent?.ToLowerInvariant(), _global, _profile); return Task.FromResult(0); } } diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs index 9be88fc1..3b67b63a 100644 --- a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -12,12 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.IO; using System.Threading.Tasks; using SeqCli.Skills; using SeqCli.Util; -using Serilog; namespace SeqCli.Cli.Commands.Skills; @@ -43,14 +40,7 @@ public InstallCommand() protected override Task Run() { - var skillsPath = Path.Combine( - _global ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Environment.CurrentDirectory, - $".{_agent?.ToLowerInvariant() ?? "agents"}", - "skills"); - - Log.Information("Installing skills to {SkillsPath}", skillsPath); - SkillInstaller.Install(skillsPath); - + SkillInstaller.Install(_agent?.ToLowerInvariant(), _global); return Task.FromResult(0); } } \ No newline at end of file diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index b1314a85..60a0f23c 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -58,8 +58,10 @@ static class McpServerInstaller "servers"), }; - public static void Install(string agent, bool global, string? profileName = null) + public static void Install(string? agent, bool global, string? profileName = null) { + agent ??= "agents"; + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); var path = target.ResolvePath(global); diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 123b7ea2..57b96f9c 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -20,21 +20,32 @@ namespace SeqCli.Skills; static class SkillInstaller { - public static void Install(string destinationPath) + public static void Install(string? agent, bool global) { + agent ??= "agents"; + + var destinationPath = Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + $".{agent}", + "skills"); + + Log.Information("Installing skills to {SkillsPath}", destinationPath); + var sourcePath = Path.Combine(AppContext.BaseDirectory, "Skills"); - + foreach (var skillSourceDirectory in Directory.EnumerateDirectories(sourcePath)) { var skillName = Path.GetFileName(skillSourceDirectory); var destination = Path.Combine(destinationPath, skillName); - + Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath); - + CopyFilesRecursive(skillSourceDirectory, destination); } } + static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + static void CopyFilesRecursive(string source, string destination) { Directory.CreateDirectory(destination); diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs index 6092edd0..7f9702eb 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -51,6 +51,15 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Contains("\"--profile\"", profileConfig); Assert.Contains("\"Production\"", profileConfig); + // VS Code has no supported user-global merge target. + var vscodeGlobalExit = runner.Exec("mcp install -a vscode --global", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, vscodeGlobalExit); + + var vscodeGlobalOutput = runner.LastRunProcess!.Output; + Assert.Contains("VS Code stores user-level MCP servers", vscodeGlobalOutput); + Assert.Contains("seqcli mcp install --agent vscode", vscodeGlobalOutput); + Assert.DoesNotContain("NotSupportedException", vscodeGlobalOutput); + return Task.CompletedTask; } } From 4d4f68fbb5545cf1fd2abaf6b126dbcf0c7ecd38 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 12:48:36 +1000 Subject: [PATCH 50/98] Increase test coverage for McpSession. Assisted-by: Claude 4.8 Opus --- test/SeqCli.Tests/Mcp/McpSessionTests.cs | 125 +++++++++++++++++- .../Mcp/SeqSyntaxFormatterTests.cs | 109 +++++++-------- test/SeqCli.Tests/Support/Some.cs | 23 +++- 3 files changed, 192 insertions(+), 65 deletions(-) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs index b5106f3e..ac644a36 100644 --- a/test/SeqCli.Tests/Mcp/McpSessionTests.cs +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -1,4 +1,10 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading; +using Newtonsoft.Json.Linq; using SeqCli.Mcp; +using SeqCli.Tests.Support; using Xunit; namespace SeqCli.Tests.Mcp; @@ -13,4 +19,121 @@ public void ResultIdsRoundTrip() Assert.True(McpSession.TryParseResultId(formatted, out var rt)); Assert.Equal(id, rt); } -} \ No newline at end of file + + [Fact] + public void ImportingTheSameEventReturnsTheSameId() + { + var session = new McpSession(); + + var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + + Assert.Equal(first, second); + } + + [Fact] + public void ImportingDistinctEventsReturnsDistinctIds() + { + var session = new McpSession(); + + var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-2")); + + Assert.NotEqual(first, second); + } + + [Fact] + public void ImportedEventsCanBeRetrievedById() + { + var session = new McpSession(); + var evt = Some.MakeEvent(); + + var resultId = session.ImportSearchResult(evt); + + Assert.True(session.TryGetSearchResult(resultId, out var result, out var error)); + Assert.Same(evt, result); + Assert.Null(error); + } + + [Fact] + public void MalformedResultIdsAreRejected() + { + var session = new McpSession(); + + Assert.False(session.TryGetSearchResult("not-a-result-id", out var result, out var error)); + Assert.Null(result); + Assert.NotNull(error); + } + + [Fact] + public void WellFormedButUnknownResultIdsReturnAnError() + { + var session = new McpSession(); + var unknown = McpSession.FormatResultId(999); + + Assert.False(session.TryGetSearchResult(unknown, out var result, out var error)); + Assert.Null(result); + Assert.NotNull(error); + } + + [Fact] + public void NoUserPropertyNamesAreEnumeratedWithoutResults() + { + var session = new McpSession(); + + Assert.Empty(session.EnumerateUserPropertyNames(CancellationToken.None)); + } + + [Fact] + public void UserPropertyNamesAreEnumeratedAcrossPropertiesScopeAndResource() + { + var session = new McpSession(); + session.ImportSearchResult(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Properties = Some.MakeProperties(("UserId", 42)); + e.Scope = Some.MakeProperties(("name", "my-scope")); + e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" })); + })); + + var names = session.EnumerateUserPropertyNames(CancellationToken.None).ToList(); + + Assert.Contains("UserId", names); + Assert.Contains("@Scope.name", names); + Assert.Contains("@Resource.service", names); + Assert.Contains("@Resource.service.name", names); + } + + [Fact] + public void UserPropertyNamesAreDeduplicatedAcrossResults() + { + var session = new McpSession(); + session.ImportSearchResult(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Properties = Some.MakeProperties(("UserId", 1)); + })); + session.ImportSearchResult(Some.MakeEvent(e => + { + e.Id = "event-2"; + e.Properties = Some.MakeProperties(("UserId", 2)); + })); + + var names = session.EnumerateUserPropertyNames(CancellationToken.None).ToList(); + + Assert.Equal(["UserId"], names); + } + + [Fact] + public void EnumeratingUserPropertyNamesObservesCancellation() + { + var session = new McpSession(); + session.ImportSearchResult(Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42)))); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.Throws( + () => session.EnumerateUserPropertyNames(cts.Token).ToList()); + } +} diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs index c883b49c..59e12ee9 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -2,11 +2,10 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; -using Seq.Api.Model.Shared; using SeqCli.Mcp.Formatting; +using SeqCli.Tests.Support; using Xunit; namespace SeqCli.Tests.Mcp; @@ -35,27 +34,27 @@ public void BuiltInPropertiesAreFormatted(EventEntity evt, string expectedLitera public static IEnumerable BuiltInPropertyCases() => [ - [MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], - [MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], - [MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], - [MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], + [Some.MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], + [Some.MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], + [Some.MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], + [Some.MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], [ - MakeEvent(e => e.MessageTemplateTokens = + Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]), "@MessageTemplate: 'User {UserId}'" ], - [MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], - [MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], - [MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], - [MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], - [MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"], - [MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"], - [MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"], - [MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"], - [MakeEvent(e => e.Properties = MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], - [MakeEvent(e => e.Scope = MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], - [MakeEvent(e => e.Resource = MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], - [MakeEvent(e => e.Definitions = MakeProperties(("d", 1))), "@Definitions: {d: 1}"] + [Some.MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], + [Some.MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], + [Some.MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], + [Some.MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], + [Some.MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"], + [Some.MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"], + [Some.MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"], + [Some.MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], + [Some.MakeEvent(e => e.Scope = Some.MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], + [Some.MakeEvent(e => e.Resource = Some.MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], + [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"] ]; [Theory] @@ -72,22 +71,29 @@ public static IEnumerable BuiltInPropertyCases() => [InlineData("@Definitions")] public void OptionalPropertiesAreOmittedWhenAbsent(string token) { - Assert.DoesNotContain(token, Render(MakeEvent())); + Assert.DoesNotContain(token, Render(Some.MakeEvent())); } [Fact] public void EmptyPropertyCollectionIsOmitted() { - Assert.DoesNotContain("@Properties", Render(MakeEvent(e => e.Properties = []))); + Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = []))); } [Fact] public void EventFormatIsAnObjectLiteral() { Assert.Equal( - "{@Id: 'event-1', @Timestamp: DateTime('2024-01-01T00:00:00.0000000Z'), " + - "@Level: 'Information', @Message: 'Hello', @MessageTemplate: 'Hello', @EventType: 0}", - Render(MakeEvent())); + "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " + + "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}", + Render(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Timestamp = "2024-01-02T00:00:00.0000002Z"; + e.RenderedMessage = "Hello!"; + e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }]; + e.EventType = "$00000001"; + }))); } [Theory] @@ -99,20 +105,20 @@ public void BasicPropertiesAreFormatted(EventEntity evt, string expectedLiteral) public static IEnumerable BasicPropertyFormattingCases() => [ - [MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], - [MakeEvent(e => e.Level = null), "@Level: 'Information'"], - [MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" + [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], + [Some.MakeEvent(e => e.Level = null), "@Level: 'Information'"], + [Some.MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" ], - [MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], - [MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], - [MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" + [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], + [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], + [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" ], - [MakeEvent(e => e.Properties = MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], - [MakeEvent(e => e.Properties = MakeProperties(("n", 42))), "@Properties: {n: 42}"], - [MakeEvent(e => e.Properties = MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], - [MakeEvent(e => e.Properties = MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], - [MakeEvent(e => e.Properties = MakeProperties(("b", true))), "@Properties: {b: true}"], - [MakeEvent(e => e.Properties = MakeProperties(("z", null))), "@Properties: {z: null}"] + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("n", 42))), "@Properties: {n: 42}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("b", true))), "@Properties: {b: true}"], + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"] ]; [Theory] @@ -125,51 +131,34 @@ public void NestedPropertiesAreFormatted(EventEntity evt, string expectedLiteral public static IEnumerable NestedPropertyCases() => [ [ - MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web" }))), + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }))), "@Resource: {service: {name: 'web'}}" ], [ - MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))), "@Resource: {service: {name: 'web', version: '1.0'}}" ], [ - MakeEvent(e => e.Resource = MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), + Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))), "@Resource: {service: {namespace: {name: 'web'}}}" ], [ - MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))), "@Properties: {http: {request: {method: 'GET'}}}" ], [ - MakeEvent(e => e.Scope = MakeProperties(("db", new JObject { ["system"] = "postgres" }))), + Some.MakeEvent(e => e.Scope = Some.MakeProperties(("db", new JObject { ["system"] = "postgres" }))), "@Scope: {db: {system: 'postgres'}}" ], [ - MakeEvent(e => e.Properties = MakeProperties(("http", new JObject { ["content-type"] = "json" }))), + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["content-type"] = "json" }))), "@Properties: {http: {'content-type': 'json'}}" ], [ - MakeEvent(e => e.Properties = MakeProperties(("tags", new JArray("a", "b")))), + Some.MakeEvent(e => e.Properties = Some.MakeProperties(("tags", new JArray("a", "b")))), "@Properties: {tags: ['a', 'b']}" ] ]; - - static EventEntity MakeEvent(Action? configure = null) - { - var evt = new EventEntity - { - Id = "event-1", - Timestamp = "2024-01-01T00:00:00.0000000Z", - RenderedMessage = "Hello", - MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello" }], - EventType = "$00000000", - }; - configure?.Invoke(evt); - return evt; - } - - static List MakeProperties(params (string Name, object? Value)[] items) => - items.Select(i => new EventPropertyPart(i.Name, i.Value)).ToList(); static string Render(EventEntity evt) { diff --git a/test/SeqCli.Tests/Support/Some.cs b/test/SeqCli.Tests/Support/Some.cs index 4d6f5e3b..7ba27d31 100644 --- a/test/SeqCli.Tests/Support/Some.cs +++ b/test/SeqCli.Tests/Support/Some.cs @@ -1,6 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; using Serilog.Events; using Serilog.Parsing; @@ -29,7 +32,7 @@ public static string String() public static string UriString() { - return "http://example.com"; + return "https://example.com"; } public static byte[] Bytes(int count) @@ -38,9 +41,21 @@ public static byte[] Bytes(int count) Rng.GetBytes(bytes); return bytes; } - - public static string ApiKey() + + public static EventEntity MakeEvent(Action? configure = null) { - return string.Join("", Bytes(8).Select(v => v.ToString("x2")).ToArray()); + var evt = new EventEntity + { + Id = $"event-{String()}", + Timestamp = "2024-01-01T00:00:00.0000000Z", + RenderedMessage = "Hello", + MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello" }], + EventType = "$00000000", + }; + configure?.Invoke(evt); + return evt; } + + public static List MakeProperties(params (string Name, object? Value)[] items) => + items.Select(i => new EventPropertyPart(i.Name, i.Value)).ToList(); } \ No newline at end of file From cb5ed21cb46b2d868692b0d229adaa63c0ad9205 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 13:02:24 +1000 Subject: [PATCH 51/98] Add seq_new_session endpoint to clear state. Assisted-by: Claude Opus 4.8 --- src/SeqCli/Mcp/McpSession.cs | 10 +++++++ .../Tools/Search/SearchAndQueryToolType.cs | 10 +++++++ .../Mcp/McpSessionBasicsTestCase.cs | 7 +++++ test/SeqCli.Tests/Mcp/McpSessionTests.cs | 26 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 26c75f7e..45a04c02 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -16,6 +16,16 @@ class McpSession readonly Dictionary _resultIdToEventId = new(); readonly Dictionary _eventIdToResult = new(); + public void Clear() + { + lock (_sync) + { + _resultIdToEventId.Clear(); + _eventIdToResult.Clear(); + // Note that `_nextId` is intentionally preserved. + } + } + public string ImportSearchResult(EventEntity evt) { lock (_sync) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index acfeff03..1b274e28 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -352,6 +352,16 @@ public async Task QueryAsync( }; } + [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] + [Description("Call this before interacting with Seq tools for the first time (optimizes resource usage by clearing caches).")] + public Task NewSessionAsync(CancellationToken cancellationToken) + { + _ = cancellationToken; + session.Clear(); + return Task.CompletedTask; + } + + static void FlattenResult(QueryResultPart result, Action> writeRow) { if (result.Error != null) diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index b57ba006..66dcdf2f 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -65,6 +65,13 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm new Dictionary { ["query"] = query })); Assert.Contains("Total", queryResult); Assert.Contains("6", queryResult); + + await client.CallToolAsync("seq_new_session"); + + var staleResult = await client.CallToolAsync( + "seq_read_search_result", + new Dictionary { ["result_id"] = resultIds[0] }); + Assert.True(staleResult.IsError ?? false); } static string AssertTextResult(CallToolResult callToolResult) diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs index ac644a36..44c8dc5f 100644 --- a/test/SeqCli.Tests/Mcp/McpSessionTests.cs +++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs @@ -136,4 +136,30 @@ public void EnumeratingUserPropertyNamesObservesCancellation() Assert.Throws( () => session.EnumerateUserPropertyNames(cts.Token).ToList()); } + + [Fact] + public void ClearForgetsImportedResults() + { + var session = new McpSession(); + var resultId = session.ImportSearchResult(Some.MakeEvent()); + + session.Clear(); + + Assert.False(session.TryGetSearchResult(resultId, out var result, out var error)); + Assert.Null(result); + Assert.NotNull(error); + Assert.Empty(session.EnumerateUserPropertyNames(CancellationToken.None)); + } + + [Fact] + public void ClearPreservesTheResultIdSequence() + { + var session = new McpSession(); + var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + + session.Clear(); + + var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1")); + Assert.NotEqual(first, second); + } } From 3380f5fb1b7a471478207be0c924cd67a3ec6837 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 13:18:45 +1000 Subject: [PATCH 52/98] Factor some orthogonal API helpers out of the (crowded) tool class --- .../Mcp/Data/DataResourceGroupHelper.cs | 50 +++++++ src/SeqCli/Mcp/Data/QueryResultHelper.cs | 101 ++++++++++++++ .../Mcp/Formatting/SeqSyntaxFormatter.cs | 14 ++ src/SeqCli/Mcp/McpSession.cs | 14 ++ src/SeqCli/Mcp/Schema/EventEntitySchema.cs | 14 ++ .../Tools/Search/SearchAndQueryToolType.cs | 131 +++--------------- 6 files changed, 213 insertions(+), 111 deletions(-) create mode 100644 src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs create mode 100644 src/SeqCli/Mcp/Data/QueryResultHelper.cs diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs new file mode 100644 index 00000000..ff498d68 --- /dev/null +++ b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs @@ -0,0 +1,50 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Seq.Api; +using Seq.Api.Model.Data; + +namespace SeqCli.Mcp.Data; + +public static class DataResourceGroupHelper +{ + static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + Culture = CultureInfo.InvariantCulture, + FloatParseHandling = FloatParseHandling.Decimal, + }); + + public static async Task QueryPreserveErrorResponsesAsync(SeqConnection connection, string query, CancellationToken cancellationToken = default) + { + // Unfortunately, the `Data.QueryAsync()` API throws when the server 400s, making this case tricky. Suggests + // we should make some API client improvements... + var request = new HttpRequestMessage + { + RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), + Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") + }; + var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); + return Serializer.Deserialize( + new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Data/QueryResultHelper.cs b/src/SeqCli/Mcp/Data/QueryResultHelper.cs new file mode 100644 index 00000000..d5a5be53 --- /dev/null +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -0,0 +1,101 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Seq.Api.Model.Data; + +namespace SeqCli.Mcp.Data; + +public static class QueryResultHelper +{ + public static void Flatten(QueryResultPart result, Action> writeRow) + { + if (result.Error != null) + return; + + if (result.Rows != null) + { + writeRow(result.Columns!); + foreach (var row in result.Rows) + { + writeRow(row); + } + } + else if (result.Slices != null) + { + writeRow(new object[] {"time"}.Concat(result.Columns!)); + + var empty = result.Columns!.Select(_ => "").ToArray(); + foreach (var slice in result.Slices) + { + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); + } + if (!any) + { + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); + } + } + } + else if (result.Series != null) + { + writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); + foreach (var series in result.Series) + { + foreach (var slice in series.Slices) + { + var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); + } + if (!any) + { + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); + } + } + } + } + else + { + throw new NotImplementedException("Query result set does not conform to any expected pattern."); + } + } + + static IEnumerable MergeColumns(string[] columns, TimeseriesPart? firstSeries) + { + if (firstSeries == null) + yield break; + + var i = 0; + for (; i < firstSeries.Key.Length; ++i) + { + yield return columns[i]; + } + + yield return "time"; + + for (; i < columns.Length; ++i) + { + yield return columns[i]; + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs index 741ebed6..4a73939f 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs @@ -1,3 +1,17 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System; using System.Collections.Generic; using System.Globalization; diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 45a04c02..02606f68 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -1,3 +1,17 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs index 80312e00..d9b9f9d7 100644 --- a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -1,3 +1,17 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System.Collections.Generic; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 1b274e28..23520eeb 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -1,17 +1,27 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System; using System.Collections.Generic; using System.ComponentModel; using System.ComponentModel.DataAnnotations; -using System.Globalization; using System.IO; using System.Linq; -using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using Newtonsoft.Json; using Seq.Api; using Seq.Api.Client; using Seq.Api.Model.Data; @@ -20,6 +30,7 @@ using Seq.Syntax.Templates; using SeqCli.Cli.Commands; using SeqCli.Mapping; +using SeqCli.Mcp.Data; using SeqCli.Mcp.Formatting; using Serilog; using Serilog.Events; @@ -36,13 +47,6 @@ class SearchAndQueryToolType(McpSession session, SeqConnection connection) $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}\n{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...\n{{#end}}" ); - static readonly JsonSerializer Serializer = JsonSerializer.Create(new JsonSerializerSettings - { - DateParseHandling = DateParseHandling.None, - Culture = CultureInfo.InvariantCulture, - FloatParseHandling = FloatParseHandling.Decimal, - }); - [McpServerTool(Name = "seq_search", ReadOnly = true, Title = "Search Events")] [Description("Search Seq for log events and spans matching given criteria. Each result is prefixed with " + "a `result_id` of the form `R#####` which is valid in the current MCP session. Individual events can be " + @@ -263,14 +267,7 @@ public async Task QueryAsync( QueryResultPart result; try { - var request = new HttpRequestMessage - { - RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), - Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") - }; - var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); - result = Serializer.Deserialize( - new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; + result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, query, cancellationToken); } catch (Exception ex) { @@ -308,7 +305,7 @@ public async Task QueryAsync( var output = new StringWriter(); var first = true; - FlattenResult(result, row => + QueryResultHelper.Flatten(result, row => { if (first) { @@ -323,7 +320,6 @@ public async Task QueryAsync( output.Write(heading); } output.WriteLine(); - output.WriteLine(); } else { @@ -336,20 +332,12 @@ public async Task QueryAsync( output.Write(' '); SeqSyntaxFormatter.WriteValue(output, value); } - output.WriteLine(); } + + output.WriteLine(); }); - return new CallToolResult - { - Content = - [ - new TextContentBlock - { - Text = output.ToString() - } - ] - }; + return SimpleTextResult(output.ToString()); } [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] @@ -360,85 +348,6 @@ public Task NewSessionAsync(CancellationToken cancellationToken) session.Clear(); return Task.CompletedTask; } - - - static void FlattenResult(QueryResultPart result, Action> writeRow) - { - if (result.Error != null) - return; - - if (result.Rows != null) - { - writeRow(result.Columns!); - foreach (var row in result.Rows) - { - writeRow(row); - } - } - else if (result.Slices != null) - { - writeRow(new object[] {"time"}.Concat(result.Columns!)); - - var empty = result.Columns!.Select(_ => "").ToArray(); - foreach (var slice in result.Slices) - { - var any = false; - foreach (var row in slice.Rows) - { - any = true; - writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); - } - if (!any) - { - writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); - } - } - } - else if (result.Series != null) - { - writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); - foreach (var series in result.Series) - { - foreach (var slice in series.Slices) - { - var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); - var any = false; - foreach (var row in slice.Rows) - { - any = true; - writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); - } - if (!any) - { - writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); - } - } - } - } - else - { - throw new NotImplementedException("Query result set does not conform to any expected pattern."); - } - } - - static IEnumerable MergeColumns(IReadOnlyList columns, TimeseriesPart? firstSeries) - { - if (firstSeries == null) - yield break; - - var i = 0; - for (; i < firstSeries.Key.Length; ++i) - { - yield return columns[i]; - } - - yield return "time"; - - for (; i < columns.Count; ++i) - { - yield return columns[i]; - } - } static CallToolResult SimpleTextResult(string resultText, bool isError = false) { From 49158423e0645ef3a847a56bfd43dd415b45dece Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 15:54:44 +1000 Subject: [PATCH 53/98] Skill updates, add `mcp install` support for Qwen Code. Assisted-by: Claude Opus 4.8 --- src/SeqCli/Mcp/McpServerInstaller.cs | 9 ++ .../Resources/seq-search-and-query/SKILL.md | 82 ++++++++++++++++--- .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 9 ++ 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 60a0f23c..d9612102 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -56,6 +56,15 @@ static class McpServerInstaller "VS Code stores user-level MCP servers in settings.json; install into a project with `seqcli mcp install --agent vscode` instead.") : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), "servers"), + + // Qwen Code reads MCP servers from the `mcpServers` key of its `settings.json`, + // both user-global (`~/.qwen`) and per-project (`.qwen`) - not a standalone `mcp.json`. + ["qwen"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".qwen", + "settings.json"), + "mcpServers"), }; public static void Install(string? agent, bool global, string? profileName = null) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 0d85fffd..95283d91 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -260,7 +260,7 @@ TimeGrouping = 'time' , '(' , duration , ')' ; HavingClause = 'having' , Expr ; OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; Ordering = TimeOrdering - | Expr , [ 'ci' ] , [ 'asc' | 'desc' ] ; + | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *) TimeOrdering = 'time' ; LimitClause = 'limit' , natural ; ForClause = 'for' , ForOption , { ',' , ForOption } ; @@ -270,9 +270,15 @@ ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains metric samples. -## Expression Examples +## Schema -| Example | Effect | +Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and +framework/ecosystem-specific property names, and may do so inconsistently. When exploring, **always use the MCP schema +tool** to inspect the actual properties appearing on search results, cross-referencing with source code where necessary. + +## Example Expressions + +| Example | Purpose~~~~ | |--------------------------------------------------------------|----------------------------------------------------------------------------------------------| | `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | | `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | @@ -282,22 +288,81 @@ metric samples. | `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | | `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | | `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | +| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | + +## Example Queries + +Grouped query with ordering: + +``` +select count(*) +from stream +group by @Resource.service.name as service +order by service +``` + +## Tracing Tactics + +Reconstruct a trace in execution (start-time) order: + +``` +select + @SpanId as span_id, + @ParentId as parent_id, + @Resource.service.name as service, + @Message as span_name, + @SpanKind as kind, + @Start as start, -- raw ticks — order by THIS column + ToIsoString(@Start) as start_iso, -- readable copy, for display only + TotalMilliseconds(@Elapsed) as ms +from stream +where @TraceId = '' and Has(@Start) +order by start asc +limit 1000 -- traces can be large; if the result looks truncated, raise this +``` + +This orders rows by start time; it does NOT build the hierarchy. The call tree is assembled from `parent_id` (each row's +`@ParentId` = its parent's `@SpanId`; the root's is null). + +Note that you'll need to use the "retrieve a specific trace span" search recipe to see more about a span appearing in +these results. + +Rank services by span latency over a window: + +``` +select + count(*) as spans, + Round(TotalMilliseconds(percentile(@Elapsed, 95)), 2) as p95_ms, + Round(TotalMilliseconds(max(@Elapsed)), 2) as max_ms +from stream +where @Timestamp >= now() - 30m and Has(@Start) +group by @Resource.service.name as service -- group key: alias it, do NOT put it in the select list +having spans > 50 -- filter groups by the select alias, NOT by count(*) directly +order by p95_ms desc -- order by a selected aggregate's alias +limit 100 +``` ## Gotchas + - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. + - To order by group keys, apply an alias with `group by as ` and use `order by `. Never add the + group key to the `select` list, this will fail. + - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and + `http.response.status_code` are written exactly like this. + - **Never** put a dotted OTel name inside `[...]`. `@Resource['a.b.c']` is a single literal key (almost always undefined); + use `@Resource.a.b.c` for path navigation. - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values. - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar and built-ins described above. - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from **most recent** to least recent. - - Data in Seq servers don't always use OpenTelemetry semantic conventions. When searching or querying, only use property - names from the built-ins described above, that appear on search results, or that are returned from the search result - schema tool. + - Data in Seq servers doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property + names from the built-ins described above, that appear on search results, or that are returned from the schema tool. - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names to be used. - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an embedded literal single quote. Backslash escaping is not recognized. - - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (`ulong` with 100 ns + - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will fail: use duration literals for durations, and the `DateTime` function to convert from ISO-8601 strings. @@ -313,8 +378,5 @@ metric samples. conserve resources when speculatively exploring. - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. - - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. - - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and - `http.response.status_code` are written exactly like this. - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't re-specified. diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs index 7f9702eb..a099e9c3 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -51,6 +51,15 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Contains("\"--profile\"", profileConfig); Assert.Contains("\"Production\"", profileConfig); + // Qwen Code reads MCP servers from `mcpServers` in its `settings.json`, not an `mcp.json`. + var qwenExit = runner.Exec("mcp install -a qwen", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, qwenExit); + + var qwenConfig = File.ReadAllText(Path.Combine(tmp.Path, ".qwen/settings.json")); + Assert.Contains("\"mcpServers\"", qwenConfig); + Assert.Contains("\"seq\"", qwenConfig); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".qwen/mcp.json"))); + // VS Code has no supported user-global merge target. var vscodeGlobalExit = runner.Exec("mcp install -a vscode --global", disconnected: true, workingDirectory: tmp.Path); Assert.Equal(1, vscodeGlobalExit); From aa344ae2eb7d4cf1ba49eee9201cbdfb719914a5 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 16:05:21 +1000 Subject: [PATCH 54/98] Use the example trace id consistently --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 95283d91..6b286997 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -316,7 +316,7 @@ select ToIsoString(@Start) as start_iso, -- readable copy, for display only TotalMilliseconds(@Elapsed) as ms from stream -where @TraceId = '' and Has(@Start) +where @TraceId = '0af7651916cd43dd8448eb211c80319c' and Has(@Start) order by start asc limit 1000 -- traces can be large; if the result looks truncated, raise this ``` From 6dd69db3c64c50a4f31b344edb60f57b345e2c81 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 16:14:58 +1000 Subject: [PATCH 55/98] More skill tweaks --- .../Resources/seq-search-and-query/SKILL.md | 140 +++++++++--------- 1 file changed, 71 insertions(+), 69 deletions(-) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 6b286997..5a260218 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -25,7 +25,7 @@ All events stored in Seq use the same data model. Spans are only distinguished f | `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | | `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | | `@Id` | `string` | The event's unique id in Seq. | -| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'error'`, and `'err'` would all be typical values. | +| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'ERROR'`, and `'err'` would all be typical values. | | `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | | `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | | `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | @@ -62,63 +62,63 @@ The synthetic type name `any` is used as an alias for `null | boolean | number | These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. -| Function signature | Description | -|-------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | -| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | -| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | -| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | -| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | -| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | -| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | -| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | -| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | -| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | -| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | -| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | -| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | -| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | -| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | -| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | -| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | -| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | -| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | -| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | -| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | -| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | -| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | -| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | -| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | -| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | -| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `ToNumber(str: string): number?` | Parse string `str` as a number. | -| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | -| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | -| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | -| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | -| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `<` | Compare two numbers and return `true` if the left-hand operand is less than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `<=` | Compare two numbers and return `true` if the left-hand operand is less than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a /regular expression/, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `>` | Compare two numbers and return `true` if the left-hand operand is greater than the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `>=` | Compare two numbers and return `true` if the left-hand operand is greater than or equal to the right-hand operand. If any argument is non-numeric, the result is undefined. | -| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | -| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | -| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | +| Function signature | Description | +|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | +| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | +| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | +| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | +| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | +| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | +| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | +| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | +| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | +| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | +| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | +| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | +| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | +| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | +| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | +| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | +| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | +| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | +| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | +| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | +| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | +| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | +| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | +| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `<` | Compare two values and return `true` if the left-hand operand is less than the right-hand operand. | +| Operator `<=` | Compare two values and return `true` if the left-hand operand is less than or equal to the right-hand operand. | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `>` | Compare two values and return `true` if the left-hand operand is greater than the right-hand operand. | +| Operator `>=` | Compare two values and return `true` if the left-hand operand is greater than or equal to the right-hand operand. | +| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | +| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | +| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | ## Aggregate Functions @@ -278,17 +278,18 @@ tool** to inspect the actual properties appearing on search results, cross-refer ## Example Expressions -| Example | Purpose~~~~ | -|--------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | -| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | -| `Has(@Start)` | Match all spans (excludes log events). | -| `@Message like '%overflow%' or @Exception like '%overflow%'` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | -| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | -| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | -| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | -| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | +| Example | Purpose~~~~ | +|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| +| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | +| `Has(@Start)` | Match all spans (excludes log events). | +| `@Message like '%overflow%' ci or @Exception like '%overflow%' ci` | Given a piece of text, find events with that text in their message or exception/stack trace. | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | +| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | +| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | +| `@Level like 'err%' ci` | Perform a case-insensitive prefix search | ## Example Queries @@ -380,3 +381,4 @@ limit 100 you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't re-specified. + - All function calls and operators are case-sensitive unless the `ci` modifier is appended. From 40a1ae403cb0f75049a4713b8481b184f24e8303 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 31 May 2026 16:17:49 +1000 Subject: [PATCH 56/98] More skill tweaks --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 5a260218..95bef11c 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -276,9 +276,12 @@ Seq servers are compatible with a vast array of data sources. They may use a mix framework/ecosystem-specific property names, and may do so inconsistently. When exploring, **always use the MCP schema tool** to inspect the actual properties appearing on search results, cross-referencing with source code where necessary. +In particular, don't skip using the schema tool early in investigations just because you've seen a few events. Events are +inconsistent! Use the schema tool at least once just to be safe. + ## Example Expressions -| Example | Purpose~~~~ | +| Example | Purpose | |--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| | `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | | `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | From 0e2de672b5d21e4289c58e1fb60bb7045e8221a9 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 1 Jun 2026 08:38:39 +1000 Subject: [PATCH 57/98] Update SKILL.md to remove deprecated operators --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 95bef11c..858902ef 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -166,7 +166,7 @@ regex_char = '\/' | ? any character except '/' ? ; ### Expression -``` +```ebnf Expr = Disjunction ; Disjunction = Conjunction , { 'or' , Conjunction } ; Conjunction = Comparison , { 'and' , Comparison } ; @@ -228,7 +228,7 @@ Variable = variable ; **Disambiguation:** The `/` character introduces a regular expression when it appears at the start of input, or when the preceding token is an operator or opening delimiter — specifically one of: `and`, `or`, `not`, `(`, `[`, `,`, `=`, `<>`, `like`, `>`, `>=`, `<`, `<=`, `in`, -`is`, `&&`, `||`, `!=`, `==`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is +`is`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is the division operator. ### Queries From 9cf028874ed63ce5e9a8d188219503a319d169dc Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 1 Jun 2026 13:10:37 +1000 Subject: [PATCH 58/98] Review feedback, reduce SKILL.md token count by dropping out table/grammar whitespace --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 2 +- .../Cli/Commands/Skills/InstallCommand.cs | 4 +- src/SeqCli/Mcp/Data/QueryResultHelper.cs | 6 +- .../Resources/seq-search-and-query/SKILL.md | 346 +++++++++--------- .../Mcp/SeqSyntaxFormatterTests.cs | 118 +++--- 5 files changed, 229 insertions(+), 247 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 8d9b073c..88d3ab8c 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -37,7 +37,7 @@ public RunCommand() { _connection = Enable(); _storagePath = Enable(); - Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection.", + Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection", _ => _debug = true); } diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs index 3b67b63a..8b6c634e 100644 --- a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -29,12 +29,12 @@ public InstallCommand() { Options.Add( "g|global", - "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills.`", + "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills`", _ => _global = true); Options.Add( "a=|agent=", - "The agent name to install skills for; the default is the generic name `agents`.", + "The agent name to install skills for; the default is the generic name `agents`", t => _agent = ArgumentString.Normalize(t)); } diff --git a/src/SeqCli/Mcp/Data/QueryResultHelper.cs b/src/SeqCli/Mcp/Data/QueryResultHelper.cs index d5a5be53..613d1aa0 100644 --- a/src/SeqCli/Mcp/Data/QueryResultHelper.cs +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -20,7 +20,11 @@ namespace SeqCli.Mcp.Data; public static class QueryResultHelper -{ +{ + /// + /// Convert into a flat table. Seq reduces browser-side processing and optimizes + /// response sizes by constructing result trees for some grouped/time-sliced query results. + /// public static void Flatten(QueryResultPart result, Action> writeRow) { if (result.Error != null) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index 858902ef..d203729a 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -3,7 +3,7 @@ name: seq-search-and-query description: Search and query logs and spans in Seq. Use when interacting with Seq. license: Apache-2.0 metadata: - author: Datalust and Contributors + author: Datalust and Contributors --- Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to @@ -16,39 +16,39 @@ compute tabular, aggregate results from the same data. All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the `@Start` property. The following built-in properties are supported. -| Built in property name | Type | Description | -|------------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | -| `@Data` | `object` | A compact internal representation of the event as a single structured object. | -| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | -| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | -| `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | -| `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | -| `@Id` | `string` | The event's unique id in Seq. | -| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'ERROR'`, and `'err'` would all be typical values. | -| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | -| `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | -| `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | -| `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | -| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | -| `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | -| `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | -| `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | -| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | -| `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | -| `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | +| Built in property name | Type | Description | +|---|---|---| +| `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | +| `@Data` | `object` | A compact internal representation of the event as a single structured object. | +| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | +| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | +| `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | +| `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | +| `@Id` | `string` | The event's unique id in Seq. | +| `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'ERROR'`, and `'err'` would all be typical values. | +| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | +| `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | +| `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | +| `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | +| `@Resource` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry resource. These may follow the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | +| `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | +| `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | +| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | +| `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | +| `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | ## Type System Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. -| Type name | Description | Example literals | -|-------------|------------------------------------------------------------------------|------------------------------------------------------------------------------| -| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | -| **boolean** | The atoms `true` and `false`. | `true`, `false` | -| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | -| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | -| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | +| Type name | Description | Example literals | +|---|---|---| +| **null** | The atom `null`. Null is a value in Seq's type system. | `null` | +| **boolean** | The atoms `true` and `false`. | `true`, `false` | +| **number** | Decimal numbers with the range and precision of .NET's `decimal` type. | `0`, `12.34`, `56ms`, `DateTime('2026-05-29T10:56:01.43278Z')`, `0xa1b234ff` | +| **array** | An ordered array of values. | `[]`, `[17, null, {a: 'test'}]` | +| **object** | An unordered set of name/value pairs. | `{}`, `{a: 'test', 'b c': 17, d: []}` | In expression evaluation, Seq does not perform any type coercion. @@ -62,106 +62,106 @@ The synthetic type name `any` is used as an alias for `null | boolean | number | These built-in functions and operators work with individual values. See Aggregate Functions for information on functions like `count()` and `distinct()` that work with sets of values. -| Function signature | Description | -|-------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | -| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | -| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | -| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | -| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | -| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | -| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | -| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | -| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | -| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | -| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | -| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | -| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | -| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | -| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | -| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | -| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | -| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | -| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | -| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | -| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | -| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | -| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | -| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | -| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | -| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | -| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | -| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `ToNumber(str: string): number?` | Parse string `str` as a number. | -| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | -| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | -| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | -| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | -| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | -| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `<` | Compare two values and return `true` if the left-hand operand is less than the right-hand operand. | -| Operator `<=` | Compare two values and return `true` if the left-hand operand is less than or equal to the right-hand operand. | -| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `>` | Compare two values and return `true` if the left-hand operand is greater than the right-hand operand. | -| Operator `>=` | Compare two values and return `true` if the left-hand operand is greater than or equal to the right-hand operand. | -| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | -| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | -| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | +| Function signature | Description | +|---|---| +| `Arrived(eventId: string): number?` | Evaluates to the arrival order encoded in `eventId`. The arrival order is a hint that preserves the order of events from the same source that have the same timestamp. | +| `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | +| `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | +| `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | +| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | +| `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | +| `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | +| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | +| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | +| `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | +| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | +| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | +| `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | +| `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | +| `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | +| `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | +| `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | +| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | +| `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | +| `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | +| `ToEventType(str: string): any` | Compute the event type that Seq automatically assigns to `@EventType` from the message template `str`. | +| `ToHexString(num: number): string` | Format `num` as a hexadecimal string, including leading `0x`. Decimal digits are discarded. | +| `ToIsoString(datetime: number, offset: number?): string` | Format `datetime` as an ISO-8601 string, with an optional time zone offset. Both the datetime and offset arguments are interpreted as 100-nanosecond ticks since the epoch. The offset argument defaults to 0, or UTC. Output is given with the UTC or full time zone designator, i.e. Z or ±hh:mm. | +| `ToJson(arg: any): string` | Convert the value `arg` to JSON. Can be used to convert a value (e.g. number) to a string. | +| `ToLower(str: string): string` | Convert string `str` to lowercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `ToNumber(str: string): number?` | Parse string `str` as a number. | +| `TotalMilliseconds(timespan: number \| string): number?` | Evaluates to the total number of milliseconds represented by the time span `timespan`. If `timespan` is a number, it will be interpreted as containing 100-nanosecond ticks. If `timespan` is a string, it will be parsed in the same manner as performed by `TimeSpan()`. | +| `ToTimeString(timespan: number): string` | Format `timespan` as an `d.HH:mm:ss.f` string. The `timespan` argument is interpreted as 100-nanosecond ticks. The inverse of TimeSpan. | +| `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | +| `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | +| `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | +| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | +| Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | +| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | +| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | +| Operator `<` | Compare two values and return `true` if the left-hand operand is less than the right-hand operand. | +| Operator `<=` | Compare two values and return `true` if the left-hand operand is less than or equal to the right-hand operand. | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `>` | Compare two values and return `true` if the left-hand operand is greater than the right-hand operand. | +| Operator `>=` | Compare two values and return `true` if the left-hand operand is greater than or equal to the right-hand operand. | +| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | +| Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | +| Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | ## Aggregate Functions `select` queries (see grammar below) have access to the following aggregate functions. -| Aggregate function signature | Description | Example | -|----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------| -| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | -| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | -| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | -| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | -| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | -| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | -| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | -| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | -| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | -| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | -| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | +| Aggregate function signature | Description | Example | +|---|---|---| +| `all(expr: boolean): boolean` | Return `true` if `expr` is `true` for all events in the stream. | `all(@Level = 'Error')` | +| `any(expr: boolean): boolean` | Return `true` if `expr` is `true` for any event in the stream. | `any(@Level = 'Error')` | +| `bottom(expr: any, n: number): rowset` | Compute the last `n` values that appear for `expr`. The `bottom` function cannot appear with any other aggregate functions. The default ordering for `select` queries on `stream` is time-ascending. See also: `top()`, `first()`, `last()`. | `bottom(StatusCode, 5)` | +| `count(property: any): number` | Computes the number of events that have a non-`null` value for `property`. The special property name `*` can be used to count all events. | `count(*)` | +| `distinct(expr: any): rowset` | Computes the set of distinct values for `expr`. The `distinct` function cannot appear with any other aggregate functions. `distinct()` and `count()` can be combined to count distinct values without returning them all. | `distinct(ExceptionType)` | +| `first(expr: any): any` | Returns the value of `expr` applied to the first events in the target range. | `first(Elapsed)` | +| `last(expr: any): any` | Returns the value of `expr` applied to the last events in the target range. | `last(Elapsed)` | +| `interval(): number` | In a query that groups by time, the duration of each time slice. | `count(*) / (interval() / 1d)` | +| `min(expr: number): number` | Computes the smallest value for `expr`. | `min(Elapsed / 1000)` | +| `max(expr: number): number` | Computes the largest value for `expr`. | `max(Elapsed / 1000)` | +| `mean(expr: number): number` | Computes the arithmetic mean (average) of `expr`, i.e. `sum(expr) / count(expr)`. Events where the expression is `null` or not numeric are ignored and do not contribute to the final result. | `mean(ItemCount)` | | `percentile(expr: number, p: number [, err: number?]): number` | Given a percentage `p`, calculates the value of `expr` at or below which `p` percent of the results fall. The optional `err` parameter specifies the maximum permissible error fraction. Higher error values reduce compute and memory resource consumption. | `percentile(ResponseTime, 95 , 0.01)` | -| `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | -| `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | +| `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | +| `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | ## Grammar ### Base ```ebnf -identifier = ( letter | '_' ) , { letter | digit | '_' } ; +identifier = ( letter | '_' ) , { letter | digit | '_' } ; built_in_identifier = '@' , ( letter | digit | '_' ) , { letter | digit | '_' } ; -variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; +variable = '$' , ( letter | digit | '_' ) , { letter | digit | '_' } ; letter = ? any Unicode letter ? ; -digit = ? any Unicode digit ? ; -string_literal = "'" , { string_char } , "'" ; -string_char = "''" | ? any character except single quote ? ; -number = natural , [ '.' , natural ] ; -hex_number = '0x' , hex_digit , { hex_digit } ; -natural = digit , { digit } ; -hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' - | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; -duration = { natural , time_unit }- ; -time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; -regular_expression = '/' , { regex_char } , '/' ; -regex_char = '\/' | ? any character except '/' ? ; +digit = ? any Unicode digit ? ; +string_literal = "'" , { string_char } , "'" ; +string_char = "''" | ? any character except single quote ? ; +number = natural , [ '.' , natural ] ; +hex_number = '0x' , hex_digit , { hex_digit } ; +natural = digit , { digit } ; +hex_digit = digit | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' + | 'A' | 'B' | 'C' | 'D' | 'E' | 'F' ; +duration = { natural , time_unit }- ; +time_unit = 'd' | 'h' | 'ms' | 'm' | 'us' | 'μs' | 'ns' | 's' ; +regular_expression = '/' , { regex_char } , '/' ; +regex_char = '\/' | ? any character except '/' ? ; ``` ### Expression @@ -172,57 +172,57 @@ Disjunction = Conjunction , { 'or' , Conjunction } ; Conjunction = Comparison , { 'and' , Comparison } ; Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ; comparison_op = 'not' , 'like' - | 'like' - | 'not' , 'in' - | 'in' - | '<=' | '<>' | '<' - | '>=' | '>' - | '=' ; + | 'like' + | 'not' , 'in' + | 'in' + | '<=' | '<>' | '<' + | '>=' | '>' + | '=' ; Comparand = Term , { ( '+' | '-' ) , Term } ; Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ; InnerTerm = Operand , { '^' , Operand } ; Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ; -unary_op = '-' | 'not' ; +unary_op = '-' | 'not' ; null_test = 'null' | 'not' , 'null' ; Path = Factor , { path_step } ; path_step = '.' , identifier - | '[' , ( wildcard | Expr ) , ']' ; + | '[' , ( wildcard | Expr ) , ']' ; wildcard = '?' | '*' ; Factor = '(' , Expr , ')' - | Item ; + | Item ; Item = Property - | Literal - | Function - | ArrayLiteral - | ObjectLiteral - | Conditional - | Block - | Lambda - | Variable ; + | Literal + | Function + | ArrayLiteral + | ObjectLiteral + | Conditional + | Block + | Lambda + | Variable ; Property = built_in_identifier - | identifier ; (* when not followed by '(' *) + | identifier ; (* when not followed by '(' *) Literal = string_literal - | number - | hex_number - | duration - | regular_expression - | 'true' - | 'false' - | 'null' ; + | number + | hex_number + | duration + | regular_expression + | 'true' + | 'false' + | 'null' ; Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ; function_name = identifier - | 'and' | 'not' | 'or' ; -arg_list = '*' (* only valid for count(*) *) - | [ Expr , { ',' , Expr } ] ; + | 'and' | 'not' | 'or' ; +arg_list = '*' (* only valid for count(*) *) + | [ Expr , { ',' , Expr } ] ; ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ; ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ; ObjectMember = ( identifier | string_literal ) , ':' , Expr ; Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ; -Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; -Binding = identifier , ':' , Expr ; -Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; -Variable = variable ; +Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ; +Binding = identifier , ':' , Expr ; +Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; +Variable = variable ; ``` **Disambiguation:** The `/` character introduces a regular expression when it appears at the @@ -235,32 +235,32 @@ the division operator. ```ebnf Query = [ ExplainClause ] - SelectClause - [ IntoClause ] - [ FromClause ] - [ WhereClause ] - [ GroupByClause ] - [ HavingClause ] - [ OrderByClause ] - [ LimitClause ] - [ ForClause ] ; + SelectClause + [ IntoClause ] + [ FromClause ] + [ WhereClause ] + [ GroupByClause ] + [ HavingClause ] + [ OrderByClause ] + [ LimitClause ] + [ ForClause ] ; ExplainClause = 'explain' , [ 'analyze' | 'lower' ] ; SelectClause = 'select' , SelectColumn , { ',' , SelectColumn } ; SelectColumn = '*' - | Expr , [ 'as' , identifier ] ; + | Expr , [ 'as' , identifier ] ; IntoClause = 'into' , variable ; FromClause = 'from' , source , { LateralJoin } ; -source = 'stream' | 'series' ; +source = 'stream' | 'series' ; LateralJoin = 'lateral' , Expr , 'as' , identifier ; WhereClause = 'where' , Expr ; GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; -Grouping = TimeGrouping - | Expr , [ 'ci' ] , [ 'as' , identifier ] ; +Grouping = TimeGrouping + | Expr , [ 'ci' ] , [ 'as' , identifier ] ; TimeGrouping = 'time' , '(' , duration , ')' ; HavingClause = 'having' , Expr ; OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ; -Ordering = TimeOrdering - | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *) +Ordering = TimeOrdering + | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *) TimeOrdering = 'time' ; LimitClause = 'limit' , natural ; ForClause = 'for' , ForOption , { ',' , ForOption } ; @@ -281,18 +281,18 @@ inconsistent! Use the schema tool at least once just to be safe. ## Example Expressions -| Example | Purpose | +| Example | Purpose | |--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | -| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | -| `Has(@Start)` | Match all spans (excludes log events). | +| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | +| `Has(@Start)` | Match all spans (excludes log events). | | `@Message like '%overflow%' ci or @Exception like '%overflow%' ci` | Given a piece of text, find events with that text in their message or exception/stack trace. | -| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | -| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | -| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | -| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | -| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | -| `@Level like 'err%' ci` | Perform a case-insensitive prefix search | +| `Items[?] = 'coffee'` | Wildcard "any" - check if element appears in collection. | +| `ToIsoString(@Timestamp)` | Render a numeric timestamp as ISO-8601. | +| `ToTimeString(@Elapsed)` | Render a numeric duration value as a human-readable time string. | +| `@Resource.service.name = 'unknown_service'` | Match events from a specific service (OpenTelemetry semantic convention) | +| `@TraceId = '...' and @SpanId = '...' and Has(@Start)` | Retrieve a specific trace span using a search expression | +| `@Level like 'err%' ci` | Perform a case-insensitive prefix search | ## Example Queries @@ -322,7 +322,7 @@ select from stream where @TraceId = '0af7651916cd43dd8448eb211c80319c' and Has(@Start) order by start asc -limit 1000 -- traces can be large; if the result looks truncated, raise this +limit 1000 -- traces can be large; if the result looks truncated, raise this ``` This orders rows by start time; it does NOT build the hierarchy. The call tree is assembled from `parent_id` (each row's diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs index 59e12ee9..0a4db67a 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs @@ -26,24 +26,27 @@ public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, boo } [Theory] - [MemberData(nameof(BuiltInPropertyCases))] - public void BuiltInPropertiesAreFormatted(EventEntity evt, string expectedLiteral) + [MemberData(nameof(EventPropertyCases))] + public void EventPropertiesAreFormatted(EventEntity evt, string expectedLiteral) { Assert.Contains(expectedLiteral, Render(evt)); } - public static IEnumerable BuiltInPropertyCases() => + public static IEnumerable EventPropertyCases() => [ [Some.MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"], - [Some.MakeEvent(), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], + [Some.MakeEvent(e => e.Timestamp = "2024-01-01T00:00:00.0000000Z"), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"], [Some.MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"], [Some.MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"], + [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], [ Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]), "@MessageTemplate: 'User {UserId}'" ], [Some.MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"], + [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], + [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], [Some.MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"], [Some.MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"], [Some.MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"], @@ -54,63 +57,10 @@ public static IEnumerable BuiltInPropertyCases() => [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"], [Some.MakeEvent(e => e.Scope = Some.MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"], [Some.MakeEvent(e => e.Resource = Some.MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"], - [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"] - ]; - - [Theory] - [InlineData("@Exception")] - [InlineData("@Elapsed")] - [InlineData("@TraceId")] - [InlineData("@SpanId")] - [InlineData("@SpanKind")] - [InlineData("@Start")] - [InlineData("@ParentId")] - [InlineData("@Properties")] - [InlineData("@Scope")] - [InlineData("@Resource")] - [InlineData("@Definitions")] - public void OptionalPropertiesAreOmittedWhenAbsent(string token) - { - Assert.DoesNotContain(token, Render(Some.MakeEvent())); - } - - [Fact] - public void EmptyPropertyCollectionIsOmitted() - { - Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = []))); - } - - [Fact] - public void EventFormatIsAnObjectLiteral() - { - Assert.Equal( - "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " + - "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}", - Render(Some.MakeEvent(e => - { - e.Id = "event-1"; - e.Timestamp = "2024-01-02T00:00:00.0000002Z"; - e.RenderedMessage = "Hello!"; - e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }]; - e.EventType = "$00000001"; - }))); - } - - [Theory] - [MemberData(nameof(BasicPropertyFormattingCases))] - public void BasicPropertiesAreFormatted(EventEntity evt, string expectedLiteral) - { - Assert.Contains(expectedLiteral, Render(evt)); - } - - public static IEnumerable BasicPropertyFormattingCases() => - [ - [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"], + [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"], [Some.MakeEvent(e => e.Level = null), "@Level: 'Information'"], [Some.MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')" ], - [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"], - [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"], [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" ], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], @@ -118,18 +68,7 @@ public static IEnumerable BasicPropertyFormattingCases() => [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("b", true))), "@Properties: {b: true}"], - [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"] - ]; - - [Theory] - [MemberData(nameof(NestedPropertyCases))] - public void NestedPropertiesAreFormatted(EventEntity evt, string expectedLiteral) - { - Assert.Contains(expectedLiteral, Render(evt)); - } - - public static IEnumerable NestedPropertyCases() => - [ + [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"], [ Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }))), "@Resource: {service: {name: 'web'}}" @@ -160,6 +99,45 @@ public static IEnumerable NestedPropertyCases() => ] ]; + [Theory] + [InlineData("@Exception")] + [InlineData("@Elapsed")] + [InlineData("@TraceId")] + [InlineData("@SpanId")] + [InlineData("@SpanKind")] + [InlineData("@Start")] + [InlineData("@ParentId")] + [InlineData("@Properties")] + [InlineData("@Scope")] + [InlineData("@Resource")] + [InlineData("@Definitions")] + public void OptionalPropertiesAreOmittedWhenAbsent(string token) + { + Assert.DoesNotContain(token, Render(Some.MakeEvent())); + } + + [Fact] + public void EmptyPropertyCollectionIsOmitted() + { + Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = []))); + } + + [Fact] + public void EventFormatIsAnObjectLiteral() + { + Assert.Equal( + "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " + + "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}", + Render(Some.MakeEvent(e => + { + e.Id = "event-1"; + e.Timestamp = "2024-01-02T00:00:00.0000002Z"; + e.RenderedMessage = "Hello!"; + e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }]; + e.EventType = "$00000001"; + }))); + } + static string Render(EventEntity evt) { var output = new StringWriter(); From d343c764723bcb2ce2c78437295e306862b55ce5 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 2 Jun 2026 16:26:24 +1000 Subject: [PATCH 59/98] Update skill, fix some CLI bugs and paper cuts --- src/SeqCli/Cli/Command.cs | 2 +- src/SeqCli/Cli/CommandLineHost.cs | 46 +-- .../Cli/Commands/Cluster/HealthCommand.cs | 1 + src/SeqCli/Cli/Commands/CommandAliases.cs | 81 +++++ src/SeqCli/Cli/Commands/Node/HealthCommand.cs | 1 + src/SeqCli/Cli/Commands/QueryCommand.cs | 17 +- src/SeqCli/Cli/Commands/SearchCommand.cs | 68 +--- src/SeqCli/Cli/Commands/TailCommand.cs | 14 +- src/SeqCli/Cli/Features/OutputFormat.cs | 176 ---------- .../Cli/Features/OutputFormatFeature.cs | 24 +- src/SeqCli/Mcp/Schema/EventEntitySchema.cs | 4 +- .../Tools/Search/SearchAndQueryToolType.cs | 45 +-- .../NativeFormatter.cs} | 47 ++- src/SeqCli/Output/OutputFormat.cs | 311 ++++++++++++++++++ src/SeqCli/Output/OutputSyntax.cs | 32 ++ .../Resources/seq-search-and-query/SKILL.md | 136 ++++---- .../NativeFormatterTests.cs} | 12 +- 17 files changed, 597 insertions(+), 420 deletions(-) create mode 100644 src/SeqCli/Cli/Commands/CommandAliases.cs delete mode 100644 src/SeqCli/Cli/Features/OutputFormat.cs rename src/SeqCli/{Mcp/Formatting/SeqSyntaxFormatter.cs => Output/NativeFormatter.cs} (84%) create mode 100644 src/SeqCli/Output/OutputFormat.cs create mode 100644 src/SeqCli/Output/OutputSyntax.cs rename test/SeqCli.Tests/{Mcp/SeqSyntaxFormatterTests.cs => Output/NativeFormatterTests.cs} (94%) diff --git a/src/SeqCli/Cli/Command.cs b/src/SeqCli/Cli/Command.cs index 4b366b49..d715cc70 100644 --- a/src/SeqCli/Cli/Command.cs +++ b/src/SeqCli/Cli/Command.cs @@ -82,7 +82,7 @@ protected virtual async Task Run(string[] unrecognized) { if (unrecognized.Any()) { - ShowUsageErrors(new [] { "Unrecognized options: " + string.Join(", ", unrecognized) }); + ShowUsageErrors(["Unrecognized options: " + string.Join(", ", unrecognized)]); return 1; } diff --git a/src/SeqCli/Cli/CommandLineHost.cs b/src/SeqCli/Cli/CommandLineHost.cs index 8b1298fe..24508499 100644 --- a/src/SeqCli/Cli/CommandLineHost.cs +++ b/src/SeqCli/Cli/CommandLineHost.cs @@ -19,43 +19,28 @@ using System.Runtime.InteropServices; using System.Threading.Tasks; using Autofac.Features.Metadata; +using SeqCli.Cli.Commands; using Serilog.Core; using Serilog.Events; namespace SeqCli.Cli; -class CommandLineHost +class CommandLineHost(IEnumerable, CommandMetadata>> availableCommands) { - readonly List, CommandMetadata>> _availableCommands; - - public CommandLineHost(IEnumerable, CommandMetadata>> availableCommands) - { - _availableCommands = availableCommands.ToList(); - } + readonly List, CommandMetadata>> _availableCommands = availableCommands.ToList(); public async Task Run(string[] args, LoggingLevelSwitch levelSwitch) { var ea = Assembly.GetEntryAssembly(); var name = ea!.GetName().Name; - if (args.Length > 0) + if (CommandAliases.RewriteArgs( + ref args, + out var commandName, + out var subCommandName, + out var featureVisibility, + out var verbose)) { - const string prereleaseArg = "--pre", verboseArg = "--verbose"; - - var commandName = args[0].ToLowerInvariant(); - var subCommandName = args.Length > 1 && !args[1].Contains('-') ? args[1].ToLowerInvariant() : null; - - var hiddenLegacyCommand = false; - if (subCommandName == null && commandName == "config") - { - hiddenLegacyCommand = true; - subCommandName = "legacy"; - } - - var featureVisibility = FeatureVisibility.Visible | FeatureVisibility.Hidden; - if (args.Any(a => a.Trim() is prereleaseArg)) - featureVisibility |= FeatureVisibility.Preview; - var currentPlatform = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? SupportedPlatforms.Windows : SupportedPlatforms.Posix; @@ -67,23 +52,16 @@ public async Task Run(string[] args, LoggingLevelSwitch levelSwitch) if (cmd != null) { - var amountToSkip = cmd.Metadata.SubCommand == null || hiddenLegacyCommand ? 1 : 2; - var commandSpecificArgs = args.Skip(amountToSkip).Where(arg => cmd.Metadata.Name == "help" || arg is not prereleaseArg).ToArray(); - - var verbose = commandSpecificArgs.Any(arg => arg == verboseArg); if (verbose) - { levelSwitch.MinimumLevel = LogEventLevel.Information; - commandSpecificArgs = commandSpecificArgs.Where(arg => arg != verboseArg).ToArray(); - } var impl = cmd.Value.Value; - return await impl.Invoke(commandSpecificArgs); + return await impl.Invoke(args); } } - + Console.WriteLine($"Usage: {name} []"); Console.WriteLine($"Type `{name} help` for available commands"); return 1; } -} \ No newline at end of file +} diff --git a/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs b/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs index a239739e..0d77a1dc 100644 --- a/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Cluster/HealthCommand.cs @@ -21,6 +21,7 @@ using SeqCli.Util; using Seq.Api.Model.Cluster; using SeqCli.Api; +using SeqCli.Output; using Serilog; namespace SeqCli.Cli.Commands.Cluster; diff --git a/src/SeqCli/Cli/Commands/CommandAliases.cs b/src/SeqCli/Cli/Commands/CommandAliases.cs new file mode 100644 index 00000000..f139393e --- /dev/null +++ b/src/SeqCli/Cli/Commands/CommandAliases.cs @@ -0,0 +1,81 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace SeqCli.Cli.Commands; + +static class CommandAliases +{ + public static bool RewriteArgs( + ref string[] args, + [NotNullWhen(true)] out string? commandName, + out string? subCommandName, + out FeatureVisibility featureVisibility, + out bool verbose) + { + if (args.Length == 0) + { + commandName = null; + subCommandName = null; + featureVisibility = FeatureVisibility.None; + verbose = false; + return false; + } + + featureVisibility = FeatureVisibility.Visible | FeatureVisibility.Hidden; + if (args.Any(arg => IsFlag(arg, "pre"))) + { + featureVisibility |= FeatureVisibility.Preview; + args = args.Where(arg => !IsFlag(arg, "pre")).ToArray(); + } + + verbose = args.Any(arg => IsFlag(arg, "verbose")); + if (verbose) + args = args.Where(arg => !IsFlag(arg, "verbose")).ToArray(); + + commandName = args[0].ToLowerInvariant(); + args = args.Skip(1).ToArray(); + + if (commandName == "--version") + { + commandName = "version"; + } + else if (commandName == "--help") + { + commandName = "help"; + } + + subCommandName = commandName != "help" && args.Length != 0 && !args[0].StartsWith('-') ? args[0].ToLowerInvariant() : null; + if (subCommandName != null) + { + args = args.Skip(1).ToArray(); + } + + if (Array.FindIndex(args, arg => IsFlag(arg, "help")) is var index and not -1) + { + args = args.Where((_, i) => i != index).ToArray(); + if (subCommandName != null) + { + args = [subCommandName, ..args]; + subCommandName = null; + } + args = [commandName, ..args]; + commandName = "help"; + } + + if (subCommandName == null && commandName == "config") + { + subCommandName = "legacy"; + } + + return true; + } + + static bool IsFlag(string flag, string flagName) + { + return flag.EndsWith(flagName, StringComparison.OrdinalIgnoreCase) && + flag[0] == '-' && + (flag.Length == flagName.Length + 1 || + flag.Length == flagName.Length + 2 && flag[1] == '-'); + } +} diff --git a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs index 5a3fcc37..6cabd844 100644 --- a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs @@ -22,6 +22,7 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; +using SeqCli.Output; using Serilog; namespace SeqCli.Cli.Commands.Node; diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 17381bea..2f7b4bb5 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; -using Newtonsoft.Json; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; @@ -43,7 +41,7 @@ public QueryCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); _connection = Enable(); @@ -63,17 +61,16 @@ protected override async Task Run() var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); var output = _output.GetOutputFormat(config); - if (output.Json) + if (output.Text) { - var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - - // Some friendlier JSON output is definitely possible here - Console.WriteLine(JsonConvert.SerializeObject(result)); + // We can fold this into the `WriteQueryResult` case once that path supports themes. + var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); + output.WriteCsv(result); } else { - var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - output.WriteCsv(result); + var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); + output.WriteQueryResult(result); } return 0; diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index cb8166b8..a9b1e049 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -14,18 +14,12 @@ using System; using System.Globalization; -using System.Linq; using System.Threading.Tasks; -using Newtonsoft.Json.Linq; -using Seq.Api.Model.Events; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Mapping; -using SeqCli.Util; using Serilog; -using Serilog.Events; -using Serilog.Parsing; + // ReSharper disable UnusedType.Global namespace SeqCli.Cli.Commands; @@ -56,7 +50,7 @@ public SearchCommand() v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); _signal = Enable(); @@ -77,7 +71,7 @@ protected override async Task Run() try { var config = RuntimeConfigurationLoader.Load(_storagePath); - await using var output = _output.GetOutputFormat(config).CreateOutputLogger(); + var output = _output.GetOutputFormat(config); var connection = SeqConnectionFactory.Connect(_connection, config); connection.Client.HttpClient.Timeout = TimeSpan.FromMilliseconds(_httpClientTimeout); @@ -95,9 +89,10 @@ protected override async Task Run() _count, fromDateUtc: _range.Start, toDateUtc: _range.End, - trace: _trace)) + trace: _trace, + render: output.RequiresRender)) { - output.Write(ToSerilogEvent(evt)); + output.WriteEventEntity(evt); } return 0; @@ -114,9 +109,10 @@ protected override async Task Run() _count, fromDateUtc: _range.Start, toDateUtc: _range.End, - trace: _trace)) + trace: _trace, + render: output.RequiresRender)) { - output.Write(ToSerilogEvent(evt)); + output.WriteEventEntity(evt); } return 0; @@ -127,50 +123,4 @@ protected override async Task Run() return 1; } } - - internal static LogEvent ToSerilogEvent(EventEntity evt) - { - return new LogEvent( - DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(), - LevelMapping.ToSerilogLevel(evt.Level), - string.IsNullOrWhiteSpace(evt.Exception) ? null : new TextException(evt.Exception), - new MessageTemplate(evt.MessageTemplateTokens.Select(ToMessageTemplateToken)), - evt.Properties - .Select(p => CreateProperty(p.Name, p.Value)) - ); - } - - static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart token) - { - // Not ideal, we lose renderings, alignment etc. here. - - if (token.Text != null) - return new TextToken(token.Text); - return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}"); - } - - static LogEventProperty CreateProperty(string name, object value) - { - return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); - } - - static LogEventPropertyValue CreatePropertyValue(object value) - { - switch (value) - { - case JObject jo: - jo.TryGetValue("$typeTag", out var tt); - return new StructureValue( - jo.Properties() - .Where(kvp => kvp.Name != "$typeTag") - .Select(kvp => CreateProperty(kvp.Name, kvp.Value)), - (tt as JValue)?.Value as string); - - case JArray ja: - return new SequenceValue(ja.Select(CreatePropertyValue)); - - default: - return new ScalarValue(value); - } - } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 75fa33b4..3da0b991 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -18,7 +18,6 @@ using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Ingestion; namespace SeqCli.Cli.Commands; @@ -39,7 +38,7 @@ public TailCommand() "An optional server-side filter to apply to the stream, for example `@Level = 'Error'`", v => _filter = v); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); _signal = Enable(); _connection = Enable(); @@ -60,18 +59,17 @@ protected override async Task Run() strict = converted.StrictExpression; } - await using var output = _output.GetOutputFormat(config).CreateOutputLogger(); - + var output = _output.GetOutputFormat(config); + try { - await foreach (var json in connection.Events.StreamDocumentsAsync( + await foreach (var evt in connection.Events.StreamAsync( filter: strict, signal: _signal.Signal, - clef: true, + render: true, cancellationToken: cancel.Token)) { - var evt = JsonLogEventReader.ReadFromJson(json); - output.Write(evt); + output.WriteEventEntity(evt); } } catch (OperationCanceledException) diff --git a/src/SeqCli/Cli/Features/OutputFormat.cs b/src/SeqCli/Cli/Features/OutputFormat.cs deleted file mode 100644 index 3af5b881..00000000 --- a/src/SeqCli/Cli/Features/OutputFormat.cs +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright © Datalust Pty Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -using System; -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq; -using Seq.Api.Model; -using SeqCli.Csv; -using SeqCli.Output; -using SeqCli.Util; -using Serilog; -using Serilog.Core; -using Serilog.Events; -using Serilog.Sinks.SystemConsole.Themes; -using Serilog.Templates.Themes; - -namespace SeqCli.Cli.Features; - -sealed class OutputFormat(bool json, bool noColor, bool forceColor) -{ - public const string DefaultOutputTemplate = - "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; - - public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; - - public static readonly ConsoleTheme DefaultTheme = - OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; - - static readonly TemplateTheme DefaultTemplateTheme = Serilog.Templates.Themes.TemplateTheme.Code; - - public bool Json => json; - - bool ApplyThemeToRedirectedOutput => noColor == false && forceColor; - - ConsoleTheme Theme - => noColor ? ConsoleTheme.None - : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme - : DefaultTheme; - - TemplateTheme? TemplateTheme - => noColor ? null - : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme - : null; - - public Logger CreateOutputLogger() - { - var outputConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(LevelAlias.Minimum) - .Enrich.With(); - - if (json) - { - outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); - } - else - { - outputConfiguration.WriteTo.Console( - outputTemplate: DefaultOutputTemplate, - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); - } - - return outputConfiguration.CreateLogger(); - } - - public void WriteCsv(string csv) - { - if (noColor ) - { - Console.Write(csv); - } - else - { - var tokens = new CsvTokenizer().Tokenize(csv); - CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); - } - } - - public void WriteEntity(Entity entity) - { - if (entity == null) throw new ArgumentNullException(nameof(entity)); - - var jo = JObject.FromObject( - entity, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); - - if (json) - { - jo.Remove("Links"); - // Proof-of-concept; this is a very inefficient - // way to write colorized JSON ;) - - var writer = new LoggerConfiguration() - .Destructure.With() - .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) - .CreateLogger(); - writer.Information("{@Entity}", jo); - } - else - { - var dyn = (dynamic) jo; - Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); - } - } - - public void WriteObject(object value) - { - if (value == null) throw new ArgumentNullException(nameof(value)); - - if (json) - { - var jo = JObject.FromObject( - value, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); - - // Using the same method of JSON colorization as above - - var writer = new LoggerConfiguration() - .Destructure.With() - .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) - .CreateLogger(); - writer.Information("{@Entity}", jo); - } - else - { - Console.WriteLine(value.ToString()); - } - } - - public void ListEntities(IEnumerable list) - { - foreach (var entity in list) - { - WriteEntity(entity); - } - } - - // ReSharper disable once MemberCanBeMadeStatic.Global -#pragma warning disable CA1822 - public void WriteText(string? text) -#pragma warning restore CA1822 - { - Console.WriteLine(text?.TrimEnd()); - } -} diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index ded3629f..fde2b7b8 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -13,17 +13,25 @@ // limitations under the License. using SeqCli.Config; +using SeqCli.Output; namespace SeqCli.Cli.Features; -class OutputFormatFeature : CommandFeature +class OutputFormatFeature(bool supportNative) : CommandFeature { - bool _json; + OutputSyntax _syntax = OutputSyntax.Text; bool? _noColor, _forceColor; + + // ReSharper disable once UnusedMember.Global + public OutputFormatFeature() + : this(false) { } public OutputFormat GetOutputFormat(SeqCliConfig config) { - return new OutputFormat(_json, _noColor ?? config.Output.DisableColor, _forceColor ?? config.Output.ForceColor); + return new OutputFormat( + _syntax, + _noColor ?? config.Output.DisableColor, + _forceColor ?? config.Output.ForceColor); } public override void Enable(OptionSet options) @@ -31,7 +39,15 @@ public override void Enable(OptionSet options) options.Add( "json", "Print output in newline-delimited JSON (the default is plain text)", - _ => _json = true); + _ => _syntax = OutputSyntax.Json); + + if (supportNative) + { + options.Add( + "native", + "Print output using Seq's native value syntax (ideal for agent usage)", + _ => _syntax = OutputSyntax.Native); + } options.Add("no-color", "Don't colorize text output", _ => _noColor = true); diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs index d9b9f9d7..1a72bc75 100644 --- a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -15,7 +15,7 @@ using System.Collections.Generic; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; -using SeqCli.Mcp.Formatting; +using SeqCli.Output; namespace SeqCli.Mcp.Schema; @@ -46,7 +46,7 @@ public static IEnumerable EnumeratePropertyAccessorPaths(EventEntity evt static IEnumerable EnumerateAccessorPaths(string prefixPath, bool optionalPrefix, string propertyName, object? propertyValue, int depth) { - var name = SeqSyntaxFormatter.MakeIdentifier(prefixPath, propertyName, optionalPrefix); + var name = NativeFormatter.MakeIdentifier(prefixPath, propertyName, optionalPrefix); yield return name; if (depth < MaxAccessorPathDepth && propertyValue is JObject jo) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index 23520eeb..a1250459 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -28,12 +28,12 @@ using Seq.Api.Model.Events; using Seq.Api.Model.Expressions; using Seq.Syntax.Templates; -using SeqCli.Cli.Commands; using SeqCli.Mapping; using SeqCli.Mcp.Data; -using SeqCli.Mcp.Formatting; +using SeqCli.Output; using Serilog; using Serilog.Events; +using NativeFormatter = SeqCli.Output.NativeFormatter; // ReSharper disable UnusedMember.Global @@ -198,7 +198,7 @@ public async Task SearchEventsAsync( foreach (var result in takenResults) { var resultId = session.ImportSearchResult(result); - var serilogEvent = SearchCommand.ToSerilogEvent(result); + var serilogEvent = OutputFormat.ToSerilogEvent(result); serilogEvent.AddOrUpdateProperty(new LogEventProperty(ResultIdPropertyName, new ScalarValue(resultId))); serilogEvent.AddOrUpdateProperty(new LogEventProperty(LevelMapping.SurrogateLevelProperty, new ScalarValue(result.Level ?? "Information"))); SearchResultFormatter.Format(serilogEvent, responseText); @@ -231,14 +231,15 @@ public Task ReadSearchResultJsonAsync( } var resultText = new StringWriter(); - SeqSyntaxFormatter.WriteEvent(resultText, result); + NativeFormatter.WriteEvent(resultText, result); return Task.FromResult(SimpleTextResult(resultText.ToString())); } [McpServerTool(Name = "seq_inspect_result_schema", ReadOnly = true, Title = "Inspect Search Result Schema")] [Description("List the user-defined top-level, scope, and resource property names observed on events " + - "in search results so far in this session. Only events retrieved in search results are considered.")] + "in search results so far in this session. Only events retrieved in search results are considered. " + + "Critically important for task accuracy.")] [return: Description("A list containing Seq syntax-formatted property names.")] public Task InspectSchemaAsync(CancellationToken cancellationToken) { @@ -304,39 +305,7 @@ public async Task QueryAsync( } var output = new StringWriter(); - var first = true; - QueryResultHelper.Flatten(result, row => - { - if (first) - { - first = false; - var firstCol = true; - foreach (var heading in row) - { - if (firstCol) - firstCol = false; - else - output.Write(' '); - output.Write(heading); - } - output.WriteLine(); - } - else - { - var firstCol = true; - foreach (var value in row) - { - if (firstCol) - firstCol = false; - else - output.Write(' '); - SeqSyntaxFormatter.WriteValue(output, value); - } - } - - output.WriteLine(); - }); - + NativeFormatter.WriteQueryResult(output, result); return SimpleTextResult(output.ToString()); } diff --git a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs b/src/SeqCli/Output/NativeFormatter.cs similarity index 84% rename from src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs rename to src/SeqCli/Output/NativeFormatter.cs index 4a73939f..c906ce4d 100644 --- a/src/SeqCli/Mcp/Formatting/SeqSyntaxFormatter.cs +++ b/src/SeqCli/Output/NativeFormatter.cs @@ -19,17 +19,19 @@ using System.Linq; using System.Text.RegularExpressions; using Newtonsoft.Json.Linq; +using Seq.Api.Model.Data; using Seq.Api.Model.Events; using Seq.Api.Model.Shared; +using SeqCli.Mcp.Data; using SeqCli.Syntax; -namespace SeqCli.Mcp.Formatting; +namespace SeqCli.Output; /// /// Constructs Seq syntax literals from API events. This provides a language model client with strong cues as /// to how the properties of an event should be incorporated into future queries/expressions. /// -static partial class SeqSyntaxFormatter +static partial class NativeFormatter { static readonly object UndefinedValue = new(); @@ -188,7 +190,10 @@ static uint ParseEventType(string dollarPrefixedHex) static string ReconstructTemplate(IEnumerable tokens) { - return string.Concat(tokens.Select(t => t.RawText ?? t.Text ?? $"{{{t.PropertyName}}}")); + return string.Concat(tokens.Select(t => + t.RawText ?? + t.Text?.Replace("{", "{{").Replace("}", "}}") ?? + $"{{{t.PropertyName}}}")); } static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(string, object?)> members) @@ -227,4 +232,40 @@ static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(st } output.Write('}'); } + + public static void WriteQueryResult(TextWriter output, QueryResultPart result) + { + var first = true; + QueryResultHelper.Flatten(result, row => + { + if (first) + { + first = false; + var firstCol = true; + foreach (var heading in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + output.Write(heading); + } + output.WriteLine(); + } + else + { + var firstCol = true; + foreach (var value in row) + { + if (firstCol) + firstCol = false; + else + output.Write(' '); + WriteValue(output, value); + } + } + + output.WriteLine(); + }); + } } diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs new file mode 100644 index 00000000..9a922b85 --- /dev/null +++ b/src/SeqCli/Output/OutputFormat.cs @@ -0,0 +1,311 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; +using Seq.Api.Model; +using Seq.Api.Model.Data; +using Seq.Api.Model.Events; +using SeqCli.Csv; +using SeqCli.Mapping; +using SeqCli.Util; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Parsing; +using Serilog.Sinks.SystemConsole.Themes; +using Serilog.Templates.Themes; + +namespace SeqCli.Output; + +sealed class OutputFormat +{ + readonly OutputSyntax _syntax; + readonly bool _noColor; + readonly bool _forceColor; + readonly Logger _formatter; + + public const string DefaultOutputTemplate = + "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + + public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; + + public static readonly ConsoleTheme DefaultTheme = + OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; + + static readonly TemplateTheme DefaultTemplateTheme = TemplateTheme.Code; + + public OutputFormat(OutputSyntax syntax, bool noColor, bool forceColor) + { + _syntax = syntax; + _noColor = noColor; + _forceColor = forceColor; + _formatter = CreateOutputLogger(); + } + + public bool Json => _syntax == OutputSyntax.Json; + public bool Text => _syntax == OutputSyntax.Text; + public bool Native => _syntax == OutputSyntax.Native; + + bool ApplyThemeToRedirectedOutput => !_noColor && _forceColor; + + ConsoleTheme Theme + => _noColor ? ConsoleTheme.None + : ApplyThemeToRedirectedOutput ? DefaultAnsiTheme + : DefaultTheme; + + TemplateTheme? TemplateTheme + => _noColor ? null + : ApplyThemeToRedirectedOutput ? DefaultTemplateTheme + : null; + + public bool RequiresRender => Native; + + Logger CreateOutputLogger() + { + var outputConfiguration = new LoggerConfiguration() + .MinimumLevel.Is(LevelAlias.Minimum) + .Enrich.With(); + + if (Json) + { + outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); + } + else if (Text) + { + outputConfiguration.WriteTo.Console( + outputTemplate: DefaultOutputTemplate, + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); + } + + // The logger is not configured for Native output, which avoids it. Ideally we'll shift away from using + // Serilog here, and move Text/Json over to EventEntity-driven formatters, too. + + return outputConfiguration.CreateLogger(); + } + + public void WriteCsv(string csv) + { + if (_noColor ) + { + Console.Write(csv); + } + else + { + var tokens = new CsvTokenizer().Tokenize(csv); + CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); + } + } + + public void WriteEntity(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var jo = JObject.FromObject( + entity, + JsonSerializer.CreateDefault(new JsonSerializerSettings { + DateParseHandling = DateParseHandling.None, + Converters = { + new StringEnumConverter() + } + })); + + if (Json) + { + jo.Remove("Links"); + + var writer = new LoggerConfiguration() + .Destructure.With() + .Enrich.With() + .WriteTo.Console( + outputTemplate: "{@Message:j}{NewLine}", + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .CreateLogger(); + writer.Information("{@Entity}", jo); + } + else if (Text) + { + var dyn = (dynamic) jo; + Console.WriteLine($"{entity.Id} {dyn.Title ?? dyn.Name ?? dyn.Username ?? dyn.Expression}"); + } + else + { + throw new InvalidOperationException("Native formatting not supported for entities."); + } + } + + public void WriteObject(object value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + + if (Json) + { + var jo = JObject.FromObject( + value, + JsonSerializer.CreateDefault(new JsonSerializerSettings { + DateParseHandling = DateParseHandling.None, + Converters = { + new StringEnumConverter() + } + })); + + // Using the same method of JSON colorization as above + + var writer = new LoggerConfiguration() + .Destructure.With() + .Enrich.With() + .WriteTo.Console( + outputTemplate: "{@Message:j}{NewLine}", + theme: Theme, + applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .CreateLogger(); + writer.Information("{@Entity}", jo); + } + else if (Text) + { + Console.WriteLine(value.ToString()); + } + else + { + throw new InvalidOperationException("Native formatting not supported for raw objects."); + } + } + + public void ListEntities(IEnumerable list) + { + foreach (var entity in list) + { + WriteEntity(entity); + } + } + + // ReSharper disable once MemberCanBeMadeStatic.Global +#pragma warning disable CA1822 + public void WriteText(string? text) +#pragma warning restore CA1822 + { + Console.WriteLine(text?.TrimEnd()); + } + + public void WriteQueryResult(QueryResultPart result) + { + if (Json) + { + // Some friendlier JSON output is definitely possible here + Console.WriteLine(JsonConvert.SerializeObject(result)); + } + else if (Native) + { + NativeFormatter.WriteQueryResult(Console.Out, result); + } + else + { + throw new InvalidOperationException("Plain text formatting not supported for query results."); + } + } + + public void WriteEventEntity(EventEntity evt) + { + if (Native) + { + NativeFormatter.WriteEvent(Console.Out, evt); + Console.Out.WriteLine(); + } + else + { + _formatter.Write(ToSerilogEvent(evt)); + } + } + + public static LogEvent ToSerilogEvent(EventEntity evt) + { + ActivityTraceId traceId = default; + if (!string.IsNullOrWhiteSpace(evt.TraceId)) + traceId = ActivityTraceId.CreateFromString(evt.TraceId); + + ActivitySpanId spanId = default; + if (!string.IsNullOrWhiteSpace(evt.SpanId)) + spanId = ActivitySpanId.CreateFromString(evt.SpanId); + + var serilogEvent = new LogEvent( + DateTimeOffset.ParseExact(evt.Timestamp, "o", CultureInfo.InvariantCulture).ToLocalTime(), + LevelMapping.ToSerilogLevel(evt.Level), + string.IsNullOrWhiteSpace(evt.Exception) ? null : new TextException(evt.Exception), + new MessageTemplate(evt.MessageTemplateTokens.Select(ToMessageTemplateToken)), + evt.Properties + .Select(p => CreateProperty(p.Name, p.Value)), + traceId, + spanId + ); + + if (evt.Scope?.Count > 0) + serilogEvent.AddOrUpdateProperty(new("@sa", new StructureValue(evt.Scope.Select(p => CreateProperty(p.Name, p.Value))))); + + if (evt.Resource?.Count > 0) + serilogEvent.AddOrUpdateProperty(new("@ra", new StructureValue(evt.Resource.Select(p => CreateProperty(p.Name, p.Value))))); + + if (!string.IsNullOrWhiteSpace(evt.ParentId)) + serilogEvent.AddOrUpdateProperty(new("@ps", new ScalarValue(evt.ParentId))); + + if (!string.IsNullOrWhiteSpace(evt.Start)) + serilogEvent.AddOrUpdateProperty(new("@st", new ScalarValue(evt.Start))); + + if (!string.IsNullOrWhiteSpace(evt.SpanKind)) + serilogEvent.AddOrUpdateProperty(new("@sk", new ScalarValue(evt.SpanKind))); + + return serilogEvent; + } + + static MessageTemplateToken ToMessageTemplateToken(MessageTemplateTokenPart token) + { + // Not ideal, we lose renderings, alignment etc. here. + + if (token.Text != null) + return new TextToken(token.Text); + return new PropertyToken(token.PropertyName, token.RawText ?? $"{{{token.PropertyName}}}"); + } + + static LogEventProperty CreateProperty(string name, object value) + { + return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); + } + + static LogEventPropertyValue CreatePropertyValue(object value) + { + switch (value) + { + case JObject jo: + jo.TryGetValue("$typeTag", out var tt); + return new StructureValue( + jo.Properties() + .Where(kvp => kvp.Name != "$typeTag") + .Select(kvp => CreateProperty(kvp.Name, kvp.Value)), + (tt as JValue)?.Value as string); + + case JArray ja: + return new SequenceValue(ja.Select(CreatePropertyValue)); + + default: + return new ScalarValue(value); + } + } +} diff --git a/src/SeqCli/Output/OutputSyntax.cs b/src/SeqCli/Output/OutputSyntax.cs new file mode 100644 index 00000000..1171f643 --- /dev/null +++ b/src/SeqCli/Output/OutputSyntax.cs @@ -0,0 +1,32 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace SeqCli.Output; + +enum OutputSyntax +{ + /// + /// Plain, human-readable text. + /// + Text, + /// + /// JSON (newline-delimited for JSON streams). + /// + Json, + /// + /// Seq's native value syntax. This is intended for agent use: values presented + /// in Seq's native syntax are more reliably fed back into searches/queries. + /// + Native +} \ No newline at end of file diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index d203729a..e86430eb 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -7,11 +7,13 @@ metadata: --- Seq is a storage service for log and trace telemetry. Search Seq to retrieve matching log events and spans. Query Seq to -compute tabular, aggregate results from the same data. +compute tabular, aggregate results from log events and spans. -> This skill does not currently cover interactions with metrics (the `series` storage object). +Seq's query language is **not SQL** — assuming SQL semantics will produce errors. Do NOT rely on prior knowledge of +Seq's query language. **Always** use the grammar, rules, and examples in this document as a basis for Seq queries and +searches. -## Data Model +## Event Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the `@Start` property. The following built-in properties are supported. @@ -19,14 +21,13 @@ All events stored in Seq use the same data model. Spans are only distinguished f | Built in property name | Type | Description | |---|---|---| | `@Arrived` | `number` | An integer indicating the order in which the event arrived at the Seq server relative to other events in the same batch. | -| `@Data` | `object` | A compact internal representation of the event as a single structured object. | -| `@Definitions` | `object?` | Metadata attached to metric samples, not present on log events or spans. | -| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. This is in the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | +| `@Data` | `object` | A compact internal representation of the event as a single structured object. High runtime cost, should be avoided when possible. | +| `@Elapsed` | `number?` | The elapsed duration of a span, expressed in 100 nanosecond ticks. In the same domain as Seq's duration literals such as `1s`, `23ms`, or `3d`. Only present on spans, not present on log events. | | `@EventType` | `number` | A numeric hash of the message template that was used to generate the event. The message template itself is in the `@MessageTemplate` property. | | `@Exception` | `string?` | The exception associated with the event if any, as a string. This normally incorporates the exception type, message, and stack trace. | | `@Id` | `string` | The event's unique id in Seq. | | `@Level` | `string` | The severity of a log event, or completion status of a span. Values are source-dependent, so for example `'Error'`, `'ERROR'`, and `'err'` would all be typical values. | -| `@Message` | `string` | Human-readable text associated with the event. This is often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | +| `@Message` | `string` | Human-readable text associated with the event. Often the result of substituting `@Properties` values into `@MessageTemplate`. For spans, this property carries the span name. | | `@MessageTemplate` | `string` | A message template, following the `messagetemplates.org` syntax. Message templates collectively identify events generated from the same line of logging/tracing code. | | `@ParentId` | `string?` | The `@SpanId` of the parent of a given span, if any. The parent span will always belong to the same trace, that is, share a `@TraceId` value. Only present on spans, not log events. | | `@Properties` | `object?` | An object containing the user-defined properties of a log event or span. Properties with names that are valid C-style identifiers can be accessed implicitly, so `RequestPath` is syntactically equivalent to `@Properties['RequestPath']`. Properties generally conform to naming conventions used throughout the Seq server - sometimes simple PascalCase names, and at other times using the OpenTelemetry semantic conventions. See also `@Resource` and `@Scope`. | @@ -34,10 +35,12 @@ All events stored in Seq use the same data model. Spans are only distinguished f | `@Scope` | `object?` | For an OpenTelemetry log event or span, the properties associated with the OpenTelemetry scope. These may match definitions in the OTel semantic conventions, but may also be domain-specific or user-defined. | | `@SpanId` | `string?` | The W3C span id that uniquely identifies a span within a trace. Log events recorded during the span carry the same `@SpanId` value as the span itself. | | `@SpanKind` | `string?` | The OpenTelemetry span kind. Only present on spans, not log events. | -| `@Start` | `number?` | The time at which the span started. The difference between the start time and `@Timestamp` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | +| `@Start` | `number?` | The time at which the span started. `@Timestamp` minus `@Start` is the `@Elapsed` time of the span. In the same units as Seq's duration literal syntax. | | `@Timestamp` | `number` | The time at which an event was recorded (completion time, for spans). Carried on all log events and spans. In the same units as Seq's duration literal syntax. | | `@TraceId` | `string?` | The W3C trace id that uniquely identifies a trace. All spans and log events within a trace carry the same trace id value. | +> Tip: if searching or querying with `seqcli`, pass `--native` to show results in round-trippable, agent-friendly syntax. Consider `--json` instead for programmatic scenarios, but take care: in JSON output mode, built-in property names are abbreviated. + ## Type System Stored data and intermediate values in expression evaluation are typed dynamically. Values are always one of the following types. @@ -53,7 +56,7 @@ Stored data and intermediate values in expression evaluation are typed dynamical In expression evaluation, Seq does not perform any type coercion. The results of functions and operators that receive invalid arguments are undefined, which is the absence of a value -(_undefined_ has roughly the same "poison" semantics as `NULL` in standard SQL). +(_undefined_ has the same "poison" semantics as `NULL` in standard SQL). Type notation in this document column uses the suffix `?` on a type name to indicate values that may be undefined. The synthetic type name `any` is used as an alias for `null | boolean | number | array | object`. @@ -68,24 +71,24 @@ These built-in functions and operators work with individual values. See Aggregat | `Bucket(n: number, err: number?): number` | Reduce precision of `n` by computing the midpoint of the closest logarithmic bucket. The optional `err` parameter specifies the maximum permissible error fraction. | | `Coalesce(arg0: any?, arg1: any?, ...): any?` | Evaluates to the first defined, non-`null` argument. If no argument meets this requirement, `Coalesce` returns the value of its final argument. | | `Concat(str0: string, str1: string, ...): string` | Concatenate all string arguments. No type coercion is performed. | -| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `Contains(text: string, substring: string): boolean` | Evaluates to `true` if text contains substring. Supports the `ci` modifier. | | `DatePart(datetime: number, part: string, offset: number): number?` | Compute the value of `part` for the date/time `datetime` at time zone offset `offset`. Both `datetime` and `offset` are 100-nanosecond tick values. If `part` is not a recognized part name, the result is undefined. See the documentation section on date and time handling for more information. | | `DateTime(str: string): number?` | Attempt to parse the date/time value encoded in the string `str`. If the value cannot be parsed as a date/time, the result is undefined. | | `ElementAt(collection: array \| object, index: number \| string): any?` | Access the element of the array or object `collection` at the index or key `index`. Supports the `ci` modifier. | -| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `EndsWith(text: string, substring: string): boolean` | Evaluates to `true` if `text` ends with `substring`. Supports the `ci` modifier. | | `Every(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for all elements of the array or object `collection`. | -| `FromJson(json: string): any?` | Parse the JSON-encoded string `json`. If `json` is not valid JSON, the result is undefined. This function has a high runtime cost and should be avoided when possible. | +| `FromJson(json: string): any?` | Parse JSON, producing a value of the corresponding Seq native type. If `json` is not valid JSON, the result is undefined. High runtime cost, should be avoided when possible. | | `Has(arg: any?): boolean` | Evaluates to `true` if `arg` is defined. Otherwise, if `arg` is undefined, the result is `false`. | -| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of `substring`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `IndexOf(text: string, substring: string): number` | Return the zero-based index of the first occurrence of `substring` in `text`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | | `Keys(obj: object): array` | Evaluates to an array containing the keys of the object `obj`. | -| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. Accepts a `/regular expression/` in place of substring. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | +| `LastIndexOf(text: string, substring: string): number` | Return the zero-based index of the last occurrence of `substring` in `text`. If substring is not present in text, the result is `-1`. Supports the `ci` modifier. | | `Length(arg: string \| array): number` | Evaluates to the length of the string or array `arg`. | | `Now(): number` | Evaluates to the current time, as 100-nanosecond ticks since 00:00:00 on 0001-01-01. | | `OffsetIn(timezone: string, instant: number): number?` | Determine the offset from UTC in time zone `timezone` at instant `instant`. The time zone name must be an IANA time zone name. The instant value is specified in 100-nanosecond ticks. See the documentation section on date and time handling for more information. | | `Replace(text: string, substring: string, replacement: string): string` | Replace all occurrences of `substring` in `text` with `replacement`. Accepts a `/regular expression/` in place of `substring`, in which case replacement may use `$0` to refer to the match, `$1` the first capturing group, and so on. Regular expression replacements use `$$` to escape a single dollar sign. Supports the `ci` modifier. | | `Round(value: number, places: number): number` | Round `value` to specified number of decimal places. Midpoint values (0.5) are rounded up. | | `Some(collection: array \| object, predicate): boolean` | Evaluates to `true` if the function predicate evaluates to `true` for any element of the array or object `collection`. | -| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Accepts a `/regular expression/` in place of `substring`. Supports the `ci` modifier. | +| `StartsWith(text: string, substring: string): boolean` | Evaluates to `true` if text starts with substring. Supports the `ci` modifier. | | `Substring(str: string, start: number, length: number?): string?` | Evaluates to the substring of string `str` from the zero-based index `start`, of `length` characters. If `length` is not specified, or exceeds the number of characters remaining after `start`, the result is the remainder of the string. The result is undefined if `start` is out of bounds, or if either `start` or `length` is negative. | | `TimeOfDay(datetime: number, offsetHours: number): number` | Compute the time of day of the date/time `datetime` in the time zone offset `offsetHours`. | | `TimeSpan(str: string): number?` | Attempt to parse the `d.HH:mm:ss.f` formatted time value encoded in the string `str`. If the value cannot be parsed as a time, the result is undefined. | @@ -100,22 +103,16 @@ These built-in functions and operators work with individual values. See Aggregat | `ToUpper(str: string): string` | Convert string `str` to uppercase. To compare strings in a case-insensitive manner, use the equality operator and `ci` modifier instead. | | `TypeOf(arg: any?): string` | Returns the type of value, either `'object'`, `'array'`, `'string'`, `'number'`, `'boolean'`, `'null'`, or `'undefined'`. | | `Values(obj: object): array` | Evaluates to an array containing the values of the members of object `obj`. | -| Operator `-` | Subtract one number from another. If any argument is non-numeric, the result is undefined. | +| Operator `+`, `-`, `*`, `/` | Arithmetic operators. If any argument is non-numeric, or if a divisor is zero, the result is undefined. | | Operator `-` (prefix) | Negate a number. If any argument is non-numeric, the result is undefined. | -| Operator `*` | Multiply two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `/` | Divide one number by another. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | -| Operator `%` | Compute the remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | +| Operator `%` | Remainder after dividing the left-hand operand by the right. If the right-hand operand is zero, the result is undefined. If any argument is non-numeric, the result is undefined. | | Operator `^` | Raise a number to the specified power. If any argument is non-numeric, the result is undefined. | -| Operator `+` | Add two numbers. If any argument is non-numeric, the result is undefined. | -| Operator `<` | Compare two values and return `true` if the left-hand operand is less than the right-hand operand. | -| Operator `<=` | Compare two values and return `true` if the left-hand operand is less than or equal to the right-hand operand. | -| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | -| Operator `>` | Compare two values and return `true` if the left-hand operand is greater than the right-hand operand. | -| Operator `>=` | Compare two values and return `true` if the left-hand operand is greater than or equal to the right-hand operand. | -| Operator `and` | The logical AND operator. The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `not` (prefix) | Logical NOT. Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | -| Operator `or` | The logical OR operator. The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `=` | Compare two values, returning `true` if the values are equal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If the right-hand operand is a `/regular expression/`, the result is `true` if the left-hand operand is a string that is an exact match for the regular expression. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `<>` | Compare two values, returning `true` if the values are unequal, and `false` otherwise. Structural comparison is supported, so the values may be of any type including objects and arrays. If any argument is undefined, the result is undefined. Supports the `ci` modifier. | +| Operator `<`, `<=`, `>`, `>=` | Compare two values. If any argument is undefined, the result is undefined. | +| Operator `and` | The result of `a and b` is `true` if and only if both `a` and `b` are `true`; the result is otherwise `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `not` (prefix) | Evaluates to `true` if the operand is `false`, or if the operand is undefined. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | +| Operator `or` | The result of `a or b` is `true` if either `a` or `b` is `true`; otherwise the result is `false`. Type coercion is not performed. If any argument is non-Boolean, the result is undefined. | | Operator `like` | Determine if the left-hand operand is a string matching the right-hand pattern. The pattern can contain `%` and `?` wildcards for zero-or-many, or zero-or-one characters. `%` and `?` are escaped by doubling. The inverse `not like` is also supported. | | Operator `is null` | Determine if the left-hand operand is `null` or undefined. The result is always a defined `boolean`. The inverse `is not null` is also supported. | | Operator `in` | Determine if the left-hand operand is an element of the right-hand array. | @@ -141,9 +138,19 @@ These built-in functions and operators work with individual values. See Aggregat | `sum(expr: number): number` | Calculates the sum of `expr`. Non-numeric values are ignored. | `sum(ItemsOrdered)` | | `top(expr: any, n: number): rowset` | Select the first `n` values of `expr`. The `top` function cannot appear with any other aggregate functions. | `top(StatusCode, 5)` | -## Grammar +## Schema + +The `stream` source contains log events and spans. The `series` source contains +metric samples (not discussed in this skill). + +Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and +framework/ecosystem-specific property names, and may do so inconsistently. + +When available, **always use the MCP schema tool** to inspect the actual properties appearing on search results. In +particular, don't skip schema checks early in investigations just because you've seen a few events. Events are +inconsistent! Use the schema tool at least once just to be safe. -### Base +## Base Grammar ```ebnf identifier = ( letter | '_' ) , { letter | digit | '_' } ; @@ -164,7 +171,7 @@ regular_expression = '/' , { regex_char } , '/' ; regex_char = '\/' | ? any character except '/' ? ; ``` -### Expression +### Expression Grammar ```ebnf Expr = Disjunction ; @@ -225,13 +232,7 @@ Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ; Variable = variable ; ``` -**Disambiguation:** The `/` character introduces a regular expression when it appears at the -start of input, or when the preceding token is an operator or opening delimiter — specifically -one of: `and`, `or`, `not`, `(`, `[`, `,`, `=`, `<>`, `like`, `>`, `>=`, `<`, `<=`, `in`, -`is`, `!`, `if`, `then`, `else`, `:`. In all other positions, `/` is -the division operator. - -### Queries +### Query Grammar ```ebnf Query = [ ExplainClause ] @@ -251,7 +252,7 @@ SelectColumn = '*' IntoClause = 'into' , variable ; FromClause = 'from' , source , { LateralJoin } ; source = 'stream' | 'series' ; -LateralJoin = 'lateral' , Expr , 'as' , identifier ; +LateralJoin = 'lateral' , 'unnest' , '(' , Expr , ')' , 'as' , identifier ; WhereClause = 'where' , Expr ; GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ; Grouping = TimeGrouping @@ -267,23 +268,13 @@ ForClause = 'for' , ForOption , { ',' , ForOption } ; ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ; ``` -Keywords are case-insensitive. The `stream` source contains log events and spans. The `series` source contains -metric samples. - -## Schema - -Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and -framework/ecosystem-specific property names, and may do so inconsistently. When exploring, **always use the MCP schema -tool** to inspect the actual properties appearing on search results, cross-referencing with source code where necessary. - -In particular, don't skip using the schema tool early in investigations just because you've seen a few events. Events are -inconsistent! Use the schema tool at least once just to be safe. +Keywords are case-insensitive. ## Example Expressions | Example | Purpose | |--------------------------------------------------------------------|----------------------------------------------------------------------------------------------| -| `@Timestamp >= now() - 10m` | Match events that occurred in the last ten minutes. | +| `@Timestamp >= Now() - 10m` | Match events that occurred in the last ten minutes. | | `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. | | `Has(@Start)` | Match all spans (excludes log events). | | `@Message like '%overflow%' ci or @Exception like '%overflow%' ci` | Given a piece of text, find events with that text in their message or exception/stack trace. | @@ -349,39 +340,24 @@ limit 100 ## Gotchas - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list. - - To order by group keys, apply an alias with `group by as ` and use `order by `. Never add the - group key to the `select` list, this will fail. - - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and - `http.response.status_code` are written exactly like this. - - **Never** put a dotted OTel name inside `[...]`. `@Resource['a.b.c']` is a single literal key (almost always undefined); - use `@Resource.a.b.c` for path navigation. + - To order by group keys, apply an alias with `group by as ` and use `order by `. Never add the group key to the `select` list, this will fail. + - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and `http.response.status_code` are written exactly like this. + - **Never** put a dotted OTel name inside `[...]`. `@Resource['a.b.c']` is a single literal key (almost always undefined); use `@Resource.a.b.c` for path navigation. - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values. - - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar - and built-ins described above. - - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from - **most recent** to least recent. - - Data in Seq servers doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property - names from the built-ins described above, that appear on search results, or that are returned from the schema tool. - - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names - to be used. - - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an - embedded literal single quote. Backslash escaping is not recognized. - - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns - resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will - fail: use duration literals for durations, and the `DateTime` function - to convert from ISO-8601 strings. - - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use - JavaScript's system of comparisons. + - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar and built-ins described above. + - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from **most recent** to least recent. + - Data in Seq servers doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property names from the built-ins described above, that appear on search results, or that are returned from the schema tool. + - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names to be used. + - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an embedded literal single quote. Backslash escaping is not recognized. + - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will fail: use duration literals for durations, and the `DateTime` function to convert from ISO-8601 strings. + - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use JavaScript's system of comparisons. - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value. - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with. - Regular expression evaluation is extremely expensive, avoid these as much as possible. - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations). - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`. - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`. - - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to - conserve resources when speculatively exploring. - - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, - you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. - - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't - re-specified. + - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to conserve resources when speculatively exploring. + - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`. + - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't re-specified. - All function calls and operators are case-sensitive unless the `ci` modifier is appended. diff --git a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs b/test/SeqCli.Tests/Output/NativeFormatterTests.cs similarity index 94% rename from test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs rename to test/SeqCli.Tests/Output/NativeFormatterTests.cs index 0a4db67a..82251913 100644 --- a/test/SeqCli.Tests/Mcp/SeqSyntaxFormatterTests.cs +++ b/test/SeqCli.Tests/Output/NativeFormatterTests.cs @@ -4,13 +4,13 @@ using System.IO; using Newtonsoft.Json.Linq; using Seq.Api.Model.Events; -using SeqCli.Mcp.Formatting; using SeqCli.Tests.Support; using Xunit; +using NativeFormatter = SeqCli.Output.NativeFormatter; -namespace SeqCli.Tests.Mcp; +namespace SeqCli.Tests.Output; -public class SeqSyntaxFormatterTests +public class NativeFormatterTests { [Theory] [InlineData("@Properties", "a", true, "a")] @@ -21,7 +21,7 @@ public class SeqSyntaxFormatterTests [InlineData("@Resource", "and", false, "@Resource.and")] public void IdentifiersAreIdiomaticallyFormatted(string prefix, string name, bool prefixIsOptional, string expected) { - var actual = SeqSyntaxFormatter.MakeIdentifier(prefix, name, prefixIsOptional); + var actual = NativeFormatter.MakeIdentifier(prefix, name, prefixIsOptional); Assert.Equal(expected, actual); } @@ -63,6 +63,8 @@ public static IEnumerable EventPropertyCases() => ], [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'" ], + [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "{bracketed}" }]), "@MessageTemplate: '{{bracketed}}'" + ], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("request id", 5))), "@Properties: {'request id': 5}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("n", 42))), "@Properties: {n: 42}"], [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("s", "x"))), "@Properties: {s: 'x'}"], @@ -141,7 +143,7 @@ public void EventFormatIsAnObjectLiteral() static string Render(EventEntity evt) { var output = new StringWriter(); - SeqSyntaxFormatter.WriteEvent(output, evt); + NativeFormatter.WriteEvent(output, evt); return output.ToString(); } } From 0d6eede8a52a540fca5784fe3785119aabf6f0a0 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 2 Jun 2026 19:53:28 +1000 Subject: [PATCH 60/98] seq_list_signals tool --- .../Mcp/Data/DataResourceGroupHelper.cs | 10 +++++-- .../Tools/Search/SearchAndQueryToolType.cs | 27 +++++++++++++++++-- src/SeqCli/Mcp/Tools/Search/SignalSummary.cs | 27 +++++++++++++++++++ 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 src/SeqCli/Mcp/Tools/Search/SignalSummary.cs diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs index ff498d68..8b506ef1 100644 --- a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs +++ b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs @@ -34,16 +34,22 @@ public static class DataResourceGroupHelper FloatParseHandling = FloatParseHandling.Decimal, }); - public static async Task QueryPreserveErrorResponsesAsync(SeqConnection connection, string query, CancellationToken cancellationToken = default) + public static async Task QueryPreserveErrorResponsesAsync(SeqConnection connection, string? signal, string query, CancellationToken cancellationToken = default) { // Unfortunately, the `Data.QueryAsync()` API throws when the server 400s, making this case tricky. Suggests // we should make some API client improvements... + var queryUri = "api/data?q=" + Uri.EscapeDataString(query); + if (signal != null) + queryUri += "&" + Uri.EscapeDataString(signal); + var request = new HttpRequestMessage { - RequestUri = new Uri("api/data?q=" + Uri.EscapeDataString(query), UriKind.Relative), + RequestUri = new Uri(queryUri, UriKind.Relative), Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") }; + var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); + return Serializer.Deserialize( new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; } diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index a1250459..d5300195 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -27,10 +27,12 @@ using Seq.Api.Model.Data; using Seq.Api.Model.Events; using Seq.Api.Model.Expressions; +using Seq.Api.Model.Signals; using Seq.Syntax.Templates; using SeqCli.Mapping; using SeqCli.Mcp.Data; using SeqCli.Output; +using SeqCli.Signals; using Serilog; using Serilog.Events; using NativeFormatter = SeqCli.Output.NativeFormatter; @@ -59,6 +61,9 @@ public async Task SearchEventsAsync( int limit, [Description("A Seq search expression evaluated over event properties.")] string? predicate = null, + [Description("A signal expression restricting the search space. Multiple " + + "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] + string? signal = null, CancellationToken cancellationToken = default) { if (!string.IsNullOrWhiteSpace(predicate)) @@ -101,6 +106,10 @@ public async Task SearchEventsAsync( } } + SignalExpressionPart? parsedSignalExpression = null; + if (!string.IsNullOrWhiteSpace(signal)) + parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); + var resultsLock = new Lock(); string? error = null; var results = new List(); @@ -115,6 +124,7 @@ public async Task SearchEventsAsync( filter: predicate, count: limit, render: true, + signal: parsedSignalExpression, cancellationToken: cancelEnumerateToken)) { lock (resultsLock) @@ -253,7 +263,10 @@ public Task InspectSchemaAsync(CancellationToken cancellationToken) public async Task QueryAsync( [Description("A Seq query language query.")] string query, - CancellationToken cancellationToken) + [Description("A signal expression identifying the events over which the query will run. Multiple " + + "signals are intersected with commas, and unioned with `|`, for example, `signal-1,(signal-2|signal-3)`.")] + string? signal = null, + CancellationToken cancellationToken = default) { if (query.Contains("from", StringComparison.OrdinalIgnoreCase) && (!query.Contains("where", StringComparison.OrdinalIgnoreCase) || @@ -268,7 +281,7 @@ public async Task QueryAsync( QueryResultPart result; try { - result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, query, cancellationToken); + result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, signal, query, cancellationToken); } catch (Exception ex) { @@ -317,6 +330,16 @@ public Task NewSessionAsync(CancellationToken cancellationToken) session.Clear(); return Task.CompletedTask; } + + [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals")] + [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + + "event streams while dramatically improving response times.")] + public async Task ListSignalsAsync(CancellationToken cancellationToken) + { + return (await connection.Signals.ListAsync(shared: true, partial: true, cancellationToken: cancellationToken)) + .Select(s => new SignalSummary { Id = s.Id, Title = s.Title }) + .ToArray(); + } static CallToolResult SimpleTextResult(string resultText, bool isError = false) { diff --git a/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs b/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs new file mode 100644 index 00000000..4f1a048f --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs @@ -0,0 +1,27 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.ComponentModel; + +namespace SeqCli.Mcp.Tools.Search; + +[Description("A signal is a saved, indexed filter over log events and spans.")] +class SignalSummary +{ + [Description("The signal id.")] + public required string Id { get; init; } + + [Description("A descriptive title.")] + public required string Title { get; init; } +} \ No newline at end of file From 7952cc936f5fd0fec152ad642e454580765c0c8a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Tue, 2 Jun 2026 20:15:02 +1000 Subject: [PATCH 61/98] Fixes --- src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs | 2 +- .../Mcp/Tools/Search/SearchAndQueryToolType.cs | 4 ++-- .../Skills/Resources/seq-search-and-query/SKILL.md | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs index 8b506ef1..d6d0559e 100644 --- a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs +++ b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs @@ -40,7 +40,7 @@ public static async Task QueryPreserveErrorResponsesAsync(SeqCo // we should make some API client improvements... var queryUri = "api/data?q=" + Uri.EscapeDataString(query); if (signal != null) - queryUri += "&" + Uri.EscapeDataString(signal); + queryUri += "&signal=" + Uri.EscapeDataString(signal); var request = new HttpRequestMessage { diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index d5300195..eab2da99 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -263,8 +263,8 @@ public Task InspectSchemaAsync(CancellationToken cancellationToken) public async Task QueryAsync( [Description("A Seq query language query.")] string query, - [Description("A signal expression identifying the events over which the query will run. Multiple " + - "signals are intersected with commas, and unioned with `|`, for example, `signal-1,(signal-2|signal-3)`.")] + [Description("A signal expression restricting the search space. Multiple " + + "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] string? signal = null, CancellationToken cancellationToken = default) { diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index e86430eb..ef25ffa4 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -13,6 +13,20 @@ Seq's query language is **not SQL** — assuming SQL semantics will produce erro Seq's query language. **Always** use the grammar, rules, and examples in this document as a basis for Seq queries and searches. +## Starting a Diagnostic Session + +Being "confidently wrong" is the most common and worst failure mode when working with diagnostic data. EVERY diagnostic +session MUST begin with the following steps: + +1. Check for relevant signals. Many event filtering problems have already been solved and the resulting filters saved as efficiently indexed signals. +2. Retrieve a sample of relevant events. +3. Confirm the schema of the search results (important!). +4. Inspect a subset of relevant events in full. +5. Determine how the correctness of any conclusions can be verified using real diagnostic data. + +DO NOT skip steps just because early results suggest a quicker path to a solution: this is often the path to being +confidently wrong! + ## Event Data Model All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the From b012068e76616519b4786e818e9f94c04aca1b76 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 06:31:36 +1000 Subject: [PATCH 62/98] Signal tool usage tests. Assisted-by: Claude Opus 4.8 --- src/SeqCli/Cli/Commands/QueryCommand.cs | 4 +- .../Mcp/McpSessionBasicsTestCase.cs | 35 +++------ .../Mcp/McpSignalUsageTestCase.cs | 72 +++++++++++++++++++ test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 49 +++++++++++++ 4 files changed, 133 insertions(+), 27 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 2f7b4bb5..768d6293 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System; using System.Threading.Tasks; +using Seq.Api.Client; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; @@ -72,7 +74,7 @@ protected override async Task Run() var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); output.WriteQueryResult(result); } - + return 0; } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index 66dcdf2f..080af02f 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -1,21 +1,18 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; using Seq.Api; -using SeqCli.EndToEnd.Support; using Serilog; using Xunit; namespace SeqCli.EndToEnd.Mcp; // ReSharper disable once UnusedType.Global -public partial class McpSessionBasicsTestCase : ICliTestCase +public class McpSessionBasicsTestCase : McpToolTestCase { - public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) { var runId = "mcp-" + Guid.NewGuid().ToString("n"); @@ -32,20 +29,11 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm order.Number, runId, order.Customer, order.Amount); } - var transport = new StdioClientTransport(new StdioClientTransportOptions - { - Name = "seqcli mcp run", - Command = "dotnet", - Arguments = [TestConfiguration.TestedBinary, "mcp", "run", $"--server={connection.Client.ServerUrl}"] - }); - - await using var client = await McpClient.CreateAsync(transport); - var predicate = $"RunId = '{runId}' and Customer.Tier = 'gold' and @Timestamp >= Now() - 1d"; var searchResult = AssertTextResult(await client.CallToolAsync( "seq_search", new Dictionary { ["limit"] = 10, ["predicate"] = predicate })); - var resultIds = ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); + var resultIds = OrderedSearchResultIds(searchResult); Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length); var detailResult = AssertTextResult(await client.CallToolAsync( @@ -56,8 +44,13 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm var schemaResult = AssertTextResult(await client.CallToolAsync("seq_inspect_result_schema")); foreach (var expectedPath in new[] - { "OrderNumber", "RunId", "Amount", "Customer", "Customer.Name", "Customer.Tier", "Customer.Address.City" }) + { + "OrderNumber", "RunId", "Amount", "Customer", "Customer.Name", "Customer.Tier", + "Customer.Address.City" + }) + { Assert.Contains(expectedPath, schemaResult); + } var query = $"select sum(Amount) as Total from stream where RunId = '{runId}' and @Timestamp >= Now() - 1d"; var queryResult = AssertTextResult(await client.CallToolAsync( @@ -73,14 +66,4 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm new Dictionary { ["result_id"] = resultIds[0] }); Assert.True(staleResult.IsError ?? false); } - - static string AssertTextResult(CallToolResult callToolResult) - { - var text = string.Join("\n", callToolResult.Content.OfType().Select(c => c.Text)); - Assert.False(callToolResult.IsError ?? false, text); - return text; - } - - [GeneratedRegex("R[0-9a-zA-Z]+")] - private static partial Regex ResultIdRegex(); } diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs new file mode 100644 index 00000000..2cd29f3e --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using ModelContextProtocol.Client; +using Seq.Api; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +// ReSharper disable once UnusedType.Global +public class McpSignalUsageTestCase : McpToolTestCase +{ + // Default signals included in every Seq installation. + const string Errors = "signal-m33301"; + const string Warnings = "signal-m33302"; + const string Spans = "signal-m20231011"; + const string Logs = "signal-m20231211"; + + protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) + { + var runId = "mcp-" + Guid.NewGuid().ToString("n"); + + logger.Information("Item {ItemNumber} processed in run {RunId}", 1, runId); + logger.Information("Item {ItemNumber} processed in run {RunId}", 2, runId); + logger.Warning("Item {ItemNumber} delayed in run {RunId}", 3, runId); + logger.Warning("Item {ItemNumber} delayed in run {RunId}", 4, runId); + logger.Error("Item {ItemNumber} failed in run {RunId}", 5, runId); + + var signalsResult = AssertTextResult(await client.CallToolAsync("seq_list_signals")); + foreach (var signal in new[] { Errors, Warnings, Spans, Logs }) + { + Assert.Contains(signal, signalsResult); + } + + var predicate = $"RunId = '{runId}' and @Timestamp >= Now() - 1d"; + + // Union: the two warnings plus the error. + Assert.Equal(3, await CountSearchResultsAsync(client, predicate, $"{Errors}~{Warnings}")); + + // Intersection: all of the warnings are log events, not spans. + Assert.Equal(2, await CountSearchResultsAsync(client, predicate, $"{Warnings},{Logs}")); + + var query = $"select count(*) as total from stream where {predicate}"; + + // Union: no spans were written, so only the error is counted. + Assert.Equal(1, await CountQueryResultAsync(client, query, $"{Spans}~{Errors}")); + + // Intersection with a grouped union: warnings and errors, all of which are log events. + Assert.Equal(3, await CountQueryResultAsync(client, query, $"({Errors}~{Warnings}),{Logs}")); + } + + static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal) + { + var searchResult = AssertTextResult(await client.CallToolAsync( + "seq_search", + new Dictionary { ["limit"] = 10, ["predicate"] = predicate, ["signal"] = signal })); + return OrderedSearchResultIds(searchResult).Length; + } + + static async Task CountQueryResultAsync(McpClient client, string query, string signal) + { + var queryResult = AssertTextResult(await client.CallToolAsync( + "seq_query", + new Dictionary { ["query"] = query, ["signal"] = signal })); + var lines = queryResult.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + Assert.Equal(2, lines.Length); + Assert.Equal("total", lines[0]); + return int.Parse(lines[1], CultureInfo.InvariantCulture); + } +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs new file mode 100644 index 00000000..ca3d77d9 --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -0,0 +1,49 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Mcp; + +/// +/// Base class for test cases exercising the tools provided by seqcli mcp run. The MCP server +/// is spawned over stdio and supplied to the subclass as a connected . +/// +public abstract partial class McpToolTestCase : ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = "seqcli mcp run", + Command = "dotnet", + Arguments = [TestConfiguration.TestedBinary, "mcp", "run", $"--server={connection.Client.ServerUrl}"] + }); + + await using var client = await McpClient.CreateAsync(transport); + + await ExecuteAsync(connection, logger, client); + } + + protected abstract Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client); + + protected static string AssertTextResult(CallToolResult callToolResult) + { + var text = string.Join("\n", callToolResult.Content.OfType().Select(c => c.Text)); + Assert.False(callToolResult.IsError ?? false, text); + return text; + } + + protected static string[] OrderedSearchResultIds(string searchResult) + { + return ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); + } + + [GeneratedRegex("R[0-9A-F]+")] + private static partial Regex ResultIdRegex(); +} From 3aeb12be3ba8397476200e761fd5a30f5d869afd Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 08:19:37 +1000 Subject: [PATCH 63/98] Imporove signal list tests, use structured tool results for signal listing --- .../Tools/Search/SearchAndQueryToolType.cs | 2 +- .../Mcp/McpSignalUsageTestCase.cs | 22 ++++++++++--------- test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 12 ++++++++++ 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index eab2da99..fb51e7e6 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -331,7 +331,7 @@ public Task NewSessionAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals")] + [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals", UseStructuredContent = true)] [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + "event streams while dramatically improving response times.")] public async Task ListSignalsAsync(CancellationToken cancellationToken) diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs index 2cd29f3e..f38ad8da 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs @@ -12,11 +12,13 @@ namespace SeqCli.EndToEnd.Mcp; // ReSharper disable once UnusedType.Global public class McpSignalUsageTestCase : McpToolTestCase { + record SignalSummary(string Id, string Title); + // Default signals included in every Seq installation. - const string Errors = "signal-m33301"; - const string Warnings = "signal-m33302"; - const string Spans = "signal-m20231011"; - const string Logs = "signal-m20231211"; + static readonly SignalSummary Errors = new("signal-m33301", "Errors"); + static readonly SignalSummary Warnings = new("signal-m33302", "Warnings"); + static readonly SignalSummary Spans = new("signal-m20231011", "Spans"); + static readonly SignalSummary Logs = new("signal-m20231211", "Logs"); protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) { @@ -28,27 +30,27 @@ protected override async Task ExecuteAsync(SeqConnection connection, ILogger log logger.Warning("Item {ItemNumber} delayed in run {RunId}", 4, runId); logger.Error("Item {ItemNumber} failed in run {RunId}", 5, runId); - var signalsResult = AssertTextResult(await client.CallToolAsync("seq_list_signals")); + var signals = AssertStructuredResult(await client.CallToolAsync("seq_list_signals")); foreach (var signal in new[] { Errors, Warnings, Spans, Logs }) { - Assert.Contains(signal, signalsResult); + Assert.Contains(signal, signals); } var predicate = $"RunId = '{runId}' and @Timestamp >= Now() - 1d"; // Union: the two warnings plus the error. - Assert.Equal(3, await CountSearchResultsAsync(client, predicate, $"{Errors}~{Warnings}")); + Assert.Equal(3, await CountSearchResultsAsync(client, predicate, $"{Errors.Id}~{Warnings.Id}")); // Intersection: all of the warnings are log events, not spans. - Assert.Equal(2, await CountSearchResultsAsync(client, predicate, $"{Warnings},{Logs}")); + Assert.Equal(2, await CountSearchResultsAsync(client, predicate, $"{Warnings.Id},{Logs.Id}")); var query = $"select count(*) as total from stream where {predicate}"; // Union: no spans were written, so only the error is counted. - Assert.Equal(1, await CountQueryResultAsync(client, query, $"{Spans}~{Errors}")); + Assert.Equal(1, await CountQueryResultAsync(client, query, $"{Spans.Id}~{Errors.Id}")); // Intersection with a grouped union: warnings and errors, all of which are log events. - Assert.Equal(3, await CountQueryResultAsync(client, query, $"({Errors}~{Warnings}),{Logs}")); + Assert.Equal(3, await CountQueryResultAsync(client, query, $"({Errors.Id}~{Warnings.Id}),{Logs.Id}")); } static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal) diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs index ca3d77d9..ecd633fc 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -1,4 +1,5 @@ using System.Linq; +using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using ModelContextProtocol.Client; @@ -39,6 +40,17 @@ protected static string AssertTextResult(CallToolResult callToolResult) return text; } + protected static T AssertStructuredResult(CallToolResult callToolResult) + { + AssertTextResult(callToolResult); + Assert.NotNull(callToolResult.StructuredContent); + + // Tools returning non-object values have them wrapped in a `result` property by the MCP + // SDK, because the protocol requires `structuredContent` to be an object. + var result = callToolResult.StructuredContent.Value.GetProperty("result"); + return JsonSerializer.Deserialize(result, JsonSerializerOptions.Web)!; + } + protected static string[] OrderedSearchResultIds(string searchResult) { return ResultIdRegex().Matches(searchResult).Select(m => m.Value).Distinct().ToArray(); From 757a2e61739957ff5bfcedc5a0ec5de4fcb128bf Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 08:22:58 +1000 Subject: [PATCH 64/98] Command aliases header --- src/SeqCli/Cli/Commands/CommandAliases.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/SeqCli/Cli/Commands/CommandAliases.cs b/src/SeqCli/Cli/Commands/CommandAliases.cs index f139393e..02ec9a0d 100644 --- a/src/SeqCli/Cli/Commands/CommandAliases.cs +++ b/src/SeqCli/Cli/Commands/CommandAliases.cs @@ -1,3 +1,17 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + using System; using System.Diagnostics.CodeAnalysis; using System.Linq; From f5eeb6333a7509adee5fc91cf37ebe26409fb34f Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 13:37:27 +1000 Subject: [PATCH 65/98] Fix error reporting in `seqcli query` --- src/SeqCli/Apps/Hosting/AppContainer.cs | 1 - src/SeqCli/Apps/Hosting/AppHost.cs | 1 - src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs | 1 - .../Cli/Commands/ApiKey/RemoveCommand.cs | 1 - src/SeqCli/Cli/Commands/App/DefineCommand.cs | 1 - src/SeqCli/Cli/Commands/App/InstallCommand.cs | 2 - src/SeqCli/Cli/Commands/App/ListCommand.cs | 3 +- .../Cli/Commands/App/UninstallCommand.cs | 1 - src/SeqCli/Cli/Commands/App/UpdateCommand.cs | 1 - .../Cli/Commands/AppInstance/CreateCommand.cs | 3 +- .../Cli/Commands/AppInstance/ListCommand.cs | 3 +- .../Cli/Commands/AppInstance/RemoveCommand.cs | 3 +- src/SeqCli/Cli/Commands/Bench/BenchCommand.cs | 2 - src/SeqCli/Cli/Commands/Config/SetCommand.cs | 3 - .../Cli/Commands/Dashboard/ListCommand.cs | 1 - .../Cli/Commands/Dashboard/RemoveCommand.cs | 1 - .../Cli/Commands/Dashboard/RenderCommand.cs | 19 +-- .../Commands/ExpressionIndex/CreateCommand.cs | 4 - .../Commands/ExpressionIndex/ListCommand.cs | 1 - src/SeqCli/Cli/Commands/Feed/ListCommand.cs | 1 - src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs | 1 - src/SeqCli/Cli/Commands/Index/ListCommand.cs | 4 - .../Cli/Commands/Index/SuppressCommand.cs | 2 - .../Cli/Commands/License/ShowCommand.cs | 9 +- src/SeqCli/Cli/Commands/Node/ListCommand.cs | 1 - src/SeqCli/Cli/Commands/QueryCommand.cs | 21 +-- .../Commands/RetentionPolicy/ListCommand.cs | 1 - .../Commands/RetentionPolicy/RemoveCommand.cs | 1 - .../Cli/Commands/Sample/SetupCommand.cs | 1 - .../Cli/Commands/Settings/ClearCommand.cs | 1 - src/SeqCli/Cli/Commands/Signal/ListCommand.cs | 1 - .../Cli/Commands/Signal/RemoveCommand.cs | 1 - .../Cli/Commands/Template/ExportCommand.cs | 3 +- src/SeqCli/Cli/Commands/User/ListCommand.cs | 1 - src/SeqCli/Cli/Commands/User/RemoveCommand.cs | 1 - src/SeqCli/Cli/Commands/View/CreateCommand.cs | 1 - src/SeqCli/Cli/Commands/View/ListCommand.cs | 1 - src/SeqCli/Cli/Commands/View/RemoveCommand.cs | 1 - .../Cli/Commands/Workspace/CreateCommand.cs | 3 +- .../Cli/Commands/Workspace/ListCommand.cs | 3 +- .../Cli/Commands/Workspace/RemoveCommand.cs | 3 +- src/SeqCli/Cli/Features/PropertiesFeature.cs | 1 - src/SeqCli/Cli/Features/SettingNameFeature.cs | 1 - src/SeqCli/Csv/CsvToken.cs | 14 -- src/SeqCli/Csv/CsvTokenizer.cs | 142 ----------------- src/SeqCli/Csv/CsvWriter.cs | 144 ++++++++++++------ .../SeqCliForwarderWindowsService.cs | 1 - src/SeqCli/Mcp/Data/QueryResultHelper.cs | 24 ++- src/SeqCli/Output/NativeFormatter.cs | 5 + src/SeqCli/Output/OutputFormat.cs | 18 +-- .../PlainText/Extraction/PatternElement.cs | 1 - src/SeqCli/Program.cs | 1 - src/SeqCli/Templates/Export/EntityName.cs | 7 + .../Templates/Export/TemplateSetExporter.cs | 12 +- .../Workspace/WorkspaceBasicsTestCase.cs | 4 +- test/SeqCli.Tests/Csv/CsvTokenizerTests.cs | 53 ------- 56 files changed, 159 insertions(+), 383 deletions(-) delete mode 100644 src/SeqCli/Csv/CsvToken.cs delete mode 100644 src/SeqCli/Csv/CsvTokenizer.cs delete mode 100644 test/SeqCli.Tests/Csv/CsvTokenizerTests.cs diff --git a/src/SeqCli/Apps/Hosting/AppContainer.cs b/src/SeqCli/Apps/Hosting/AppContainer.cs index 2f52c589..8a58c2bd 100644 --- a/src/SeqCli/Apps/Hosting/AppContainer.cs +++ b/src/SeqCli/Apps/Hosting/AppContainer.cs @@ -15,7 +15,6 @@ using System; using System.Globalization; using System.IO; -using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Newtonsoft.Json; diff --git a/src/SeqCli/Apps/Hosting/AppHost.cs b/src/SeqCli/Apps/Hosting/AppHost.cs index 3f48e118..aaea1ac0 100644 --- a/src/SeqCli/Apps/Hosting/AppHost.cs +++ b/src/SeqCli/Apps/Hosting/AppHost.cs @@ -14,7 +14,6 @@ using System; using System.Collections.Generic; -using System.Net; using System.Threading.Tasks; using Seq.Apps; using Serilog; diff --git a/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs index 4cd44f5c..63dffd5e 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs index ac5900b8..23254ca9 100644 --- a/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/ApiKey/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/App/DefineCommand.cs b/src/SeqCli/Cli/Commands/App/DefineCommand.cs index 40e2a54f..3844e351 100644 --- a/src/SeqCli/Cli/Commands/App/DefineCommand.cs +++ b/src/SeqCli/Cli/Commands/App/DefineCommand.cs @@ -16,7 +16,6 @@ using System.Threading.Tasks; using SeqCli.Apps; using SeqCli.Apps.Definitions; -using SeqCli.Cli.Features; using SeqCli.Util; namespace SeqCli.Cli.Commands.App; diff --git a/src/SeqCli/Cli/Commands/App/InstallCommand.cs b/src/SeqCli/Cli/Commands/App/InstallCommand.cs index b365a8d6..4c8ec3b0 100644 --- a/src/SeqCli/Cli/Commands/App/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/App/InstallCommand.cs @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Globalization; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/App/ListCommand.cs b/src/SeqCli/Cli/Commands/App/ListCommand.cs index 1add7dc5..7acd0f1a 100644 --- a/src/SeqCli/Cli/Commands/App/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/App/ListCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/App/UninstallCommand.cs b/src/SeqCli/Cli/Commands/App/UninstallCommand.cs index 16e74b3f..2dbbc516 100644 --- a/src/SeqCli/Cli/Commands/App/UninstallCommand.cs +++ b/src/SeqCli/Cli/Commands/App/UninstallCommand.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/App/UpdateCommand.cs b/src/SeqCli/Cli/Commands/App/UpdateCommand.cs index 7399f2f7..cccc8cac 100644 --- a/src/SeqCli/Cli/Commands/App/UpdateCommand.cs +++ b/src/SeqCli/Cli/Commands/App/UpdateCommand.cs @@ -13,7 +13,6 @@ // limitations under the License. using System; -using System.Globalization; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs index 4f9a024d..81756af3 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/CreateCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Seq.Api.Model.AppInstances; diff --git a/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs index f882b119..10f9609c 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/ListCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs b/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs index d72ae404..0f65c03c 100644 --- a/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/AppInstance/RemoveCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs b/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs index c38e8b6c..416ebeb4 100644 --- a/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs +++ b/src/SeqCli/Cli/Commands/Bench/BenchCommand.cs @@ -13,14 +13,12 @@ // limitations under the License. using System; -using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; using Seq.Api; -using Seq.Api.Model.Data; using Seq.Api.Model.Signals; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/Config/SetCommand.cs b/src/SeqCli/Cli/Commands/Config/SetCommand.cs index da055ff5..10bfe686 100644 --- a/src/SeqCli/Cli/Commands/Config/SetCommand.cs +++ b/src/SeqCli/Cli/Commands/Config/SetCommand.cs @@ -12,12 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.IO; using System.Threading.Tasks; using SeqCli.Cli.Features; using SeqCli.Config; -using Serilog; // ReSharper disable once UnusedType.Global diff --git a/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs index 7d9cc7a2..3f2987d1 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs index 28c0845c..52566c9e 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index 80ed95e9..fb3cac9a 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -15,7 +15,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using Newtonsoft.Json; using Seq.Api.Model.Dashboarding; using Seq.Api.Model.Signals; using SeqCli.Api; @@ -158,20 +157,10 @@ protected override async Task Run() var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); var output = _output.GetOutputFormat(config); - if (output.Json) - { - var result = await connection.Data.QueryAsync(q, signal: signal, timeout: timeout); - - // Some friendlier JSON output is definitely possible here - Console.WriteLine(JsonConvert.SerializeObject(result)); - } - else - { - var result = await connection.Data.QueryCsvAsync(q, signal: signal, timeout: timeout); - output.WriteCsv(result); - } - - return 0; + var result = await connection.Data.TryQueryAsync(q, signal: signal, timeout: timeout); + output.WriteQueryResult(result); + + return string.IsNullOrWhiteSpace(result.Error) ? 0 : 1; } static string BuildSqlQuery(ChartQueryPart query, DateTime rangeStart, DateTime rangeEnd, TimeSpan? timeGrouping) diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs index cd454a42..3f31d9c1 100644 --- a/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/CreateCommand.cs @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; -using Seq.Api.Model.Signals; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Signals; -using SeqCli.Syntax; using SeqCli.Util; using Serilog; diff --git a/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs b/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs index 133c26bc..cf54e03c 100644 --- a/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/ExpressionIndex/ListCommand.cs @@ -1,4 +1,3 @@ -using System; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/Feed/ListCommand.cs b/src/SeqCli/Cli/Commands/Feed/ListCommand.cs index f203018f..b10f245d 100644 --- a/src/SeqCli/Cli/Commands/Feed/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs index 898b55f0..99a788ed 100644 --- a/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Feed/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Index/ListCommand.cs b/src/SeqCli/Cli/Commands/Index/ListCommand.cs index 9452f36d..8b1b62a6 100644 --- a/src/SeqCli/Cli/Commands/Index/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Index/ListCommand.cs @@ -12,11 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; -using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using Seq.Api.Model.Indexes; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; diff --git a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs index da742ed1..0b970622 100644 --- a/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs +++ b/src/SeqCli/Cli/Commands/Index/SuppressCommand.cs @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; -using Seq.Api.Model.Indexes; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; diff --git a/src/SeqCli/Cli/Commands/License/ShowCommand.cs b/src/SeqCli/Cli/Commands/License/ShowCommand.cs index 5f15a451..e3d4e8e3 100644 --- a/src/SeqCli/Cli/Commands/License/ShowCommand.cs +++ b/src/SeqCli/Cli/Commands/License/ShowCommand.cs @@ -1,14 +1,7 @@ -using System; -using System.IO; -using System.Text; -using System.Threading.Tasks; -using Seq.Api.Model; -using Seq.Api.Model.License; +using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; -using SeqCli.Util; -using Serilog; // ReSharper disable once UnusedType.Global diff --git a/src/SeqCli/Cli/Commands/Node/ListCommand.cs b/src/SeqCli/Cli/Commands/Node/ListCommand.cs index a6f42b3e..16a2cfb2 100644 --- a/src/SeqCli/Cli/Commands/Node/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 768d6293..6dd7db67 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; -using Seq.Api.Client; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; @@ -24,7 +22,7 @@ namespace SeqCli.Cli.Commands; -[Command("query", "Execute an SQL query and receive results in CSV format", +[Command("query", "Execute a query and receive results in CSV, JSON, or native format", Example = "seqcli query -q \"select count(*) from stream group by @Level\" --start=\"2018-02-28T13:00Z\"")] class QueryCommand : Command { @@ -39,7 +37,7 @@ class QueryCommand : Command public QueryCommand() { - Options.Add("q=|query=", "The query to execute", v => _query = v); + Options.Add("q=|query=", "The Seq query to execute", v => _query = v); _range = Enable(); _signal = Enable(); _timeout = Enable(); @@ -63,18 +61,9 @@ protected override async Task Run() var timeout = _timeout.ApplyTimeout(connection.Client.HttpClient); var output = _output.GetOutputFormat(config); - if (output.Text) - { - // We can fold this into the `WriteQueryResult` case once that path supports themes. - var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - output.WriteCsv(result); - } - else - { - var result = await connection.Data.QueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - output.WriteQueryResult(result); - } + var result = await connection.Data.TryQueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); + output.WriteQueryResult(result); - return 0; + return string.IsNullOrWhiteSpace(result.Error) ? 0 : 1; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs index a8d410a7..32bb0414 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs index a6e6acf4..1e7e3fb3 100644 --- a/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/RetentionPolicy/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs b/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs index 187ca169..caceca7c 100644 --- a/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs +++ b/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs @@ -15,7 +15,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using System.Threading.Tasks; using SeqCli.Cli.Features; using SeqCli.Templates.Ast; diff --git a/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs b/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs index 760d9d2f..192667bc 100644 --- a/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs +++ b/src/SeqCli/Cli/Commands/Settings/ClearCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/Signal/ListCommand.cs b/src/SeqCli/Cli/Commands/Signal/ListCommand.cs index 883d1448..46f67513 100644 --- a/src/SeqCli/Cli/Commands/Signal/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs index 06f65a60..89189b33 100644 --- a/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Signal/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Template/ExportCommand.cs b/src/SeqCli/Cli/Commands/Template/ExportCommand.cs index 624db07b..5a77714a 100644 --- a/src/SeqCli/Cli/Commands/Template/ExportCommand.cs +++ b/src/SeqCli/Cli/Commands/Template/ExportCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/User/ListCommand.cs b/src/SeqCli/Cli/Commands/User/ListCommand.cs index 62ae3979..6bb8f885 100644 --- a/src/SeqCli/Cli/Commands/User/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/User/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/User/RemoveCommand.cs b/src/SeqCli/Cli/Commands/User/RemoveCommand.cs index c855c2ad..dcfe60dd 100644 --- a/src/SeqCli/Cli/Commands/User/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/User/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/View/CreateCommand.cs b/src/SeqCli/Cli/Commands/View/CreateCommand.cs index dfecae50..cad65ef4 100644 --- a/src/SeqCli/Cli/Commands/View/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/View/CreateCommand.cs @@ -17,7 +17,6 @@ using System.Linq; using System.Threading.Tasks; using Seq.Api.Model.Shared; -using Seq.Api.Model.Signals; using SeqCli.Api; using SeqCli.Cli.Features; using SeqCli.Config; diff --git a/src/SeqCli/Cli/Commands/View/ListCommand.cs b/src/SeqCli/Cli/Commands/View/ListCommand.cs index 5acc0f0a..23446e52 100644 --- a/src/SeqCli/Cli/Commands/View/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/View/ListCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/View/RemoveCommand.cs b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs index b5569e11..7f50b62b 100644 --- a/src/SeqCli/Cli/Commands/View/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs b/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs index 1554708e..60c67d1d 100644 --- a/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/CreateCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using SeqCli.Api; diff --git a/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs b/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs index 8bf57afa..3acc85ad 100644 --- a/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/ListCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs index c5efb71f..de1a26da 100644 --- a/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs +++ b/src/SeqCli/Cli/Commands/Workspace/RemoveCommand.cs @@ -1,5 +1,4 @@ -using System; -using System.Linq; +using System.Linq; using System.Threading.Tasks; using SeqCli.Api; using SeqCli.Cli.Features; diff --git a/src/SeqCli/Cli/Features/PropertiesFeature.cs b/src/SeqCli/Cli/Features/PropertiesFeature.cs index fd0dde48..d55630ed 100644 --- a/src/SeqCli/Cli/Features/PropertiesFeature.cs +++ b/src/SeqCli/Cli/Features/PropertiesFeature.cs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -using System.Collections; using System.Collections.Generic; using SeqCli.Ingestion; diff --git a/src/SeqCli/Cli/Features/SettingNameFeature.cs b/src/SeqCli/Cli/Features/SettingNameFeature.cs index 025ac25a..b9c70dcf 100644 --- a/src/SeqCli/Cli/Features/SettingNameFeature.cs +++ b/src/SeqCli/Cli/Features/SettingNameFeature.cs @@ -16,7 +16,6 @@ using System.Collections.Generic; using Seq.Api.Model.Settings; using SeqCli.Util; -using Serilog; namespace SeqCli.Cli.Features; diff --git a/src/SeqCli/Csv/CsvToken.cs b/src/SeqCli/Csv/CsvToken.cs deleted file mode 100644 index eb095c4d..00000000 --- a/src/SeqCli/Csv/CsvToken.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace SeqCli.Csv; - -enum CsvToken -{ - None, - Newline, - DoubleQuote, - Comma, - Number, - Boolean, - Null, - Text, - EscapedDoubleQuote -} \ No newline at end of file diff --git a/src/SeqCli/Csv/CsvTokenizer.cs b/src/SeqCli/Csv/CsvTokenizer.cs deleted file mode 100644 index 2f3529a4..00000000 --- a/src/SeqCli/Csv/CsvTokenizer.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using Superpower; -using Superpower.Model; -using Superpower.Parsers; - -namespace SeqCli.Csv; - -class CsvTokenizer : Tokenizer -{ - static readonly TextParser Content = Span.WithoutAny(ch => ch == '"'); - - protected override IEnumerable> Tokenize(TextSpan span) - { - var next = SkipInsignificant(span); - if (!next.HasValue) - yield break; - - do - { - // Here we're always either at the beginning of a line, or behind a comma. - if (next.Value == '"') - { - yield return Result.Value(CsvToken.DoubleQuote, next.Location, next.Remainder); - next = next.Remainder.ConsumeChar(); - if (!next.HasValue) yield break; - - var text = Content(next.Location); - while (text.HasValue || !text.Remainder.IsAtEnd) - { - if (text.HasValue) - { - if (TryMatchSpecialContent(text.Value, out var specialTokenType) && - !IsEscapedDoubleQuote(text.Remainder)) - yield return Result.Value(specialTokenType, text.Location, text.Remainder); - else - yield return Result.Value(CsvToken.Text, text.Location, text.Remainder); - } - - next = text.Remainder.ConsumeChar(); - if (!next.HasValue) yield break; - - if (next.Value != '"') - { - yield return Result.Empty(next.Location, ["double-quote"]); - yield break; - } - - var lookahead = next.Remainder.ConsumeChar(); - if (lookahead.HasValue && lookahead.Value == '"') - { - yield return Result.Value(CsvToken.EscapedDoubleQuote, next.Location, lookahead.Remainder); - next = lookahead.Remainder.ConsumeChar(); - if (!next.HasValue) yield break; - } - else - { - yield return Result.Value(CsvToken.DoubleQuote, next.Location, next.Remainder); - next = next.Remainder.ConsumeChar(); - if (!next.HasValue) yield break; - break; // Done with the content - } - - text = Content(next.Location); - } - - next = SkipInsignificant(next.Location); - if (next.Value == ',') - { - yield return Result.Value(CsvToken.Comma, next.Location, next.Remainder); - next = next.Remainder.ConsumeChar(); - if (!next.HasValue) yield break; - } - else if (next.Value == '\n') - { - yield return Result.Value(CsvToken.Newline, next.Location, next.Remainder); - next = next.Remainder.ConsumeChar(); - if (!next.HasValue) yield break; - } - else - { - yield return Result.Empty(next.Location, ["comma", "newline"]); - yield break; - } - } - else - { - yield return Result.Empty(next.Location, ["double-quote"]); - yield break; - } - - next = SkipInsignificant(next.Location); - } while (next.HasValue); - } - - static bool IsEscapedDoubleQuote(TextSpan span) - { - return span.Length >= 2 && - span.Source![span.Position.Absolute] == '"' && - span.Source[span.Position.Absolute + 1] == '"'; - } - - static bool TryMatchSpecialContent(TextSpan text, out CsvToken specialTokenType) - { - // Planning a switch from "True" to "true" for CSV Booleans - if (text.EqualsValueIgnoreCase("true") || - text.EqualsValueIgnoreCase("false")) - { - specialTokenType = CsvToken.Boolean; - return true; - } - - if (text.EqualsValue("null")) - { - specialTokenType = CsvToken.Null; - return true; - } - - // Just a quick temp job here until Superpower `Numerics` gets `Decimal` and `HexNumber`, plus - // an `IsMatch(TextSpan)` on `TextParser`. - var s = text.ToStringValue(); - if (text.Length > 0 - && text.Length < 16 - && (decimal.TryParse(text.ToStringValue(), out _) || - s.StartsWith("0x") && ulong.TryParse(s.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out _))) - { - specialTokenType = CsvToken.Number; - return true; - } - - specialTokenType = CsvToken.None; - return false; - } - - static Result SkipInsignificant(TextSpan span) - { - var result = span.ConsumeChar(); - while (result.HasValue && result.Value != '\n' && char.IsWhiteSpace(result.Value)) - result = result.Remainder.ConsumeChar(); - return result; - } -} \ No newline at end of file diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index 232f3d6b..049fd877 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -1,63 +1,111 @@ using System; -using System.Collections.Generic; +using System.Globalization; using System.IO; +using Seq.Api.Model.Data; +using SeqCli.Mcp.Data; using Serilog.Sinks.SystemConsole.Themes; -using Superpower.Model; namespace SeqCli.Csv; static class CsvWriter { - public static void WriteCsv(IEnumerable> csv, ConsoleTheme theme, TextWriter output, bool hasHeaderRow) + public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, TextWriter output) { - var isHeaderRow = hasHeaderRow; - - foreach (var token in csv) + if (!string.IsNullOrWhiteSpace(result.Error)) + { + theme.Set(output, ConsoleThemeStyle.Text); + QueryResultHelper.WriteErrorResult(output, result); + theme.Reset(output); + } + + var first = true; + QueryResultHelper.Flatten(result, row => { - switch (token.Kind) + if (first) { - case CsvToken.Newline: - output.WriteLine(); - isHeaderRow = false; - break; - case CsvToken.Comma: - theme.Set(output, ConsoleThemeStyle.TertiaryText); - output.Write(','); - theme.Reset(output); - break; - case CsvToken.DoubleQuote: - theme.Set(output, ConsoleThemeStyle.TertiaryText); - output.Write('"'); - theme.Reset(output); - break; - case CsvToken.Boolean: - theme.Set(output, ConsoleThemeStyle.Boolean); - output.Write(token.ToStringValue()); - theme.Reset(output); - break; - case CsvToken.Null: - theme.Set(output, ConsoleThemeStyle.Null); - output.Write(token.ToStringValue()); - theme.Reset(output); - break; - case CsvToken.Number: - theme.Set(output, ConsoleThemeStyle.Number); - output.Write(token.ToStringValue()); - theme.Reset(output); - break; - case CsvToken.EscapedDoubleQuote: - theme.Set(output, ConsoleThemeStyle.Scalar); - output.Write(token.ToStringValue()); - theme.Reset(output); - break; - case CsvToken.Text: - theme.Set(output, isHeaderRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text); - output.Write(token.ToStringValue()); - theme.Reset(output); - break; - default: - throw new ArgumentException($"Unrecognized token `{token}`."); + first = false; + var firstCol = true; + foreach (var heading in row) + { + if (firstCol) + { + firstCol = false; + } + else + { + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write(", "); + theme.Reset(output); + } + + WriteCell(output, theme, heading, isHeadingRow: true); + } } + else + { + var firstCol = true; + foreach (var value in row) + { + if (firstCol) + { + firstCol = false; + } + else + { + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write(", "); + theme.Reset(output); + } + + WriteCell(output, theme, value); + } + } + output.WriteLine(); + }); + } + + static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, bool isHeadingRow = false) + { + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write('"'); + theme.Reset(output); + + var valueAsString = value switch + { + null => "null", + true => "true", + false => "false", + 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 => ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture), + DateTime dt => dt.ToString("o"), + DateTimeOffset dto => dto.ToString("o"), + _ => value.ToString() ?? "" + }; + + var dataStyle = isHeadingRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text; + var doubleQuote = valueAsString.IndexOf('"'); + while (doubleQuote != -1) + { + theme.Set(output, dataStyle); + output.Write(valueAsString[..doubleQuote]); + theme.Reset(output); + + theme.Set(output, ConsoleThemeStyle.Scalar); + output.Write("\"\""); + theme.Reset(output); + + valueAsString = valueAsString[(doubleQuote + 1)..]; + doubleQuote = valueAsString.IndexOf('"'); } + + theme.Set(output, dataStyle); + output.Write(valueAsString); + theme.Reset(output); + + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write('"'); + theme.Reset(output); } } \ No newline at end of file diff --git a/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs index b07a25ac..21b21f6b 100644 --- a/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs +++ b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs @@ -13,7 +13,6 @@ // limitations under the License. using System.Diagnostics.CodeAnalysis; -using System.Net; using System.ServiceProcess; using SeqCli.Forwarder.Web.Host; diff --git a/src/SeqCli/Mcp/Data/QueryResultHelper.cs b/src/SeqCli/Mcp/Data/QueryResultHelper.cs index 613d1aa0..7d86837b 100644 --- a/src/SeqCli/Mcp/Data/QueryResultHelper.cs +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -14,12 +14,13 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Seq.Api.Model.Data; namespace SeqCli.Mcp.Data; -public static class QueryResultHelper +static class QueryResultHelper { /// /// Convert into a flat table. Seq reduces browser-side processing and optimizes @@ -102,4 +103,25 @@ static IEnumerable MergeColumns(string[] columns, TimeseriesPart? firstS yield return columns[i]; } } + + public static void WriteErrorResult(TextWriter output, QueryResultPart result) + { + output.WriteLine(result.Error); + output.WriteLine(); + + foreach (var reason in result.Reasons) + { + output.WriteLine($" - {reason}"); + } + + if (result.Reasons.Length > 0) + output.WriteLine(); + + if (!string.IsNullOrWhiteSpace(result.Suggestion)) + { + output.WriteLine("Perhaps you meant:"); + output.WriteLine(); + output.WriteLine(result.Suggestion); + } + } } \ No newline at end of file diff --git a/src/SeqCli/Output/NativeFormatter.cs b/src/SeqCli/Output/NativeFormatter.cs index c906ce4d..782a2433 100644 --- a/src/SeqCli/Output/NativeFormatter.cs +++ b/src/SeqCli/Output/NativeFormatter.cs @@ -235,6 +235,11 @@ static void WriteObject(TextWriter output, bool topLevel, params IEnumerable<(st public static void WriteQueryResult(TextWriter output, QueryResultPart result) { + if (!string.IsNullOrWhiteSpace(result.Error)) + { + QueryResultHelper.WriteErrorResult(output, result); + } + var first = true; QueryResultHelper.Flatten(result, row => { diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index 9a922b85..0e7c47d7 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -102,19 +102,6 @@ Logger CreateOutputLogger() return outputConfiguration.CreateLogger(); } - public void WriteCsv(string csv) - { - if (_noColor ) - { - Console.Write(csv); - } - else - { - var tokens = new CsvTokenizer().Tokenize(csv); - CsvWriter.WriteCsv(tokens, Theme, Console.Out, true); - } - } - public void WriteEntity(Entity entity) { if (entity == null) throw new ArgumentNullException(nameof(entity)); @@ -210,8 +197,7 @@ public void WriteQueryResult(QueryResultPart result) { if (Json) { - // Some friendlier JSON output is definitely possible here - Console.WriteLine(JsonConvert.SerializeObject(result)); + WriteObject(result); } else if (Native) { @@ -219,7 +205,7 @@ public void WriteQueryResult(QueryResultPart result) } else { - throw new InvalidOperationException("Plain text formatting not supported for query results."); + CsvWriter.WriteQueryResult(result, Theme, Console.Out); } } diff --git a/src/SeqCli/PlainText/Extraction/PatternElement.cs b/src/SeqCli/PlainText/Extraction/PatternElement.cs index 0b3c5bac..90703f43 100644 --- a/src/SeqCli/PlainText/Extraction/PatternElement.cs +++ b/src/SeqCli/PlainText/Extraction/PatternElement.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using Superpower; using Superpower.Model; diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index f58599da..5363b98f 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -17,7 +17,6 @@ using System.Threading.Tasks; using Autofac; using SeqCli.Cli; -using SeqCli.Cli.Features; using SeqCli.Util; using Serilog; using Serilog.Core; diff --git a/src/SeqCli/Templates/Export/EntityName.cs b/src/SeqCli/Templates/Export/EntityName.cs index c4bd5c40..6cf2ab56 100644 --- a/src/SeqCli/Templates/Export/EntityName.cs +++ b/src/SeqCli/Templates/Export/EntityName.cs @@ -1,5 +1,6 @@ using System; using Seq.Api.Model; +using Seq.Api.Model.Queries; namespace SeqCli.Templates.Export; @@ -9,12 +10,18 @@ public static string FromEntityType(Type entityType) { if (!typeof(Entity).IsAssignableFrom(entityType)) throw new ArgumentException("Type is not an entity type."); + + if (typeof(QueryEntity) == entityType) + return "sqlquery"; return entityType.Name.ToLowerInvariant().Replace("entity", ""); } public static string ToResourceGroup(string resource) { + if (resource == "query") + return "sqlqueries"; + if (resource.EndsWith('y')) { return resource.TrimEnd('y') + "ies"; diff --git a/src/SeqCli/Templates/Export/TemplateSetExporter.cs b/src/SeqCli/Templates/Export/TemplateSetExporter.cs index 57d8d059..05c98d68 100644 --- a/src/SeqCli/Templates/Export/TemplateSetExporter.cs +++ b/src/SeqCli/Templates/Export/TemplateSetExporter.cs @@ -11,7 +11,7 @@ using Seq.Api.Model.Indexing; using Seq.Api.Model.Retention; using Seq.Api.Model.Signals; -using Seq.Api.Model.SqlQueries; +using Seq.Api.Model.Queries; using Seq.Api.Model.Workspaces; using Serilog; @@ -35,7 +35,7 @@ public async Task ExportTemplateSet() var templateValueMap = new TemplateValueMap(); templateValueMap.MapNonNullAsArg(nameof(DashboardEntity.OwnerId), "ownerId"); templateValueMap.MapNonNullAsArg(nameof(SignalEntity.OwnerId), "ownerId"); - templateValueMap.MapNonNullAsArg(nameof(SqlQueryEntity.OwnerId), "ownerId"); + templateValueMap.MapNonNullAsArg(nameof(QueryEntity.OwnerId), "ownerId"); templateValueMap.MapNonNullAsArg(nameof(WorkspaceEntity.OwnerId), "ownerId"); templateValueMap.MapNonNullAsArg(nameof(NotificationChannelPart.NotificationAppInstanceId), "notificationAppInstanceId"); templateValueMap.MapAsReference(nameof(SignalExpressionPart.SignalId)); @@ -50,9 +50,9 @@ await ExportTemplates( signal => signal.Title, templateValueMap); - await ExportTemplates( - id => _connection.SqlQueries.FindAsync(id), - () => _connection.SqlQueries.ListAsync(shared: true), + await ExportTemplates( + id => _connection.Queries.FindAsync(id), + () => _connection.Queries.ListAsync(shared: true), query => query.Title, templateValueMap); @@ -97,7 +97,7 @@ async Task ExportTemplates( where TEntity : Entity { List entities; - if (!_include.Any()) + if (_include.Count == 0) { entities = await listEntities(); } diff --git a/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs b/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs index 114466cc..18b7d6e7 100644 --- a/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Workspace/WorkspaceBasicsTestCase.cs @@ -21,14 +21,14 @@ public async Task ExecuteAsync( exit = runner.Exec("workspace list", "-t Example"); Assert.Equal(0, exit); - var output = runner.LastRunProcess.Output; + var output = runner.LastRunProcess!.Output; Assert.Equal("", output.Trim()); var items = ""; var dashboard = (await connection.Dashboards.ListAsync(shared: true)).First(); items += $" --content={dashboard.Id}"; - var query = (await connection.SqlQueries.ListAsync(shared: true)).First(); + var query = (await connection.Queries.ListAsync(shared: true)).First(); items += $" --content={query.Id}"; foreach (var signal in (await connection.Signals.ListAsync(shared: true)).Take(2)) diff --git a/test/SeqCli.Tests/Csv/CsvTokenizerTests.cs b/test/SeqCli.Tests/Csv/CsvTokenizerTests.cs deleted file mode 100644 index 1ff31b38..00000000 --- a/test/SeqCli.Tests/Csv/CsvTokenizerTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using SeqCli.Csv; -using Superpower.Model; -using Xunit; - -namespace SeqCli.Tests.Csv; - -public class CsvTokenizerTests -{ - [Fact] - public void AnEmptyStringYieldsNoTokens() - { - Assert.Empty(Tokenize("")); - } - - [Fact] - public void TokenizesATextCell() - { - var tokens = Tokenize("\"abc\""); - Assert.Equal(3, tokens.Count()); - Assert.Equal(tokens.Select(t => t.Kind), new[]{CsvToken.DoubleQuote, CsvToken.Text, CsvToken.DoubleQuote}); - } - - [Fact] - public void TokenizesARow() - { - var tokens = Tokenize("\"abc\",\"def\"\r\n"); - Assert.Equal(8, tokens.Count()); - } - - [Theory] - [InlineData(CsvToken.Text, "abc")] - [InlineData(CsvToken.Text, "1a")] - [InlineData(CsvToken.Text, "1\"\"")] - [InlineData(CsvToken.Number, "1")] - [InlineData(CsvToken.Number, "-123.45")] - [InlineData(CsvToken.Number, "0xa123")] - [InlineData(CsvToken.Boolean, "true")] - [InlineData(CsvToken.Boolean, "false")] - [InlineData(CsvToken.Null, "null")] - public void DetectsSpecialTokenTypes(object o, string cell) - { - var tokenKind = (CsvToken) o; - var content = Tokenize($"\"{cell}\"").ElementAt(1); - Assert.Equal(tokenKind, content.Kind); - } - - static TokenList Tokenize(string csv) - { - return new CsvTokenizer().Tokenize(csv); - } -} \ No newline at end of file From f827f1fc99f795afef48e12752e52e60621f494c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 13:41:57 +1000 Subject: [PATCH 66/98] Get rid of spaces trailing commas --- src/SeqCli/Csv/CsvWriter.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index 049fd877..1cf74241 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -34,7 +34,7 @@ public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, else { theme.Set(output, ConsoleThemeStyle.TertiaryText); - output.Write(", "); + output.Write(','); theme.Reset(output); } @@ -53,7 +53,7 @@ public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, else { theme.Set(output, ConsoleThemeStyle.TertiaryText); - output.Write(", "); + output.Write(','); theme.Reset(output); } From 9a535a0bc3d5326dde5a9f45036e5f4f93b17c0a Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 14:29:05 +1000 Subject: [PATCH 67/98] Remove the data resource group helper, make sure views are exported by the `template export` command --- .../Mcp/Data/DataResourceGroupHelper.cs | 56 ------------------- .../Tools/Search/SearchAndQueryToolType.cs | 36 +++--------- .../Templates/Export/TemplateSetExporter.cs | 7 +++ .../Templates/Import/TemplateSetImporter.cs | 2 +- 4 files changed, 16 insertions(+), 85 deletions(-) delete mode 100644 src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs diff --git a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs b/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs deleted file mode 100644 index d6d0559e..00000000 --- a/src/SeqCli/Mcp/Data/DataResourceGroupHelper.cs +++ /dev/null @@ -1,56 +0,0 @@ -// 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? signal, string query, CancellationToken cancellationToken = default) - { - // Unfortunately, the `Data.QueryAsync()` API throws when the server 400s, making this case tricky. Suggests - // we should make some API client improvements... - var queryUri = "api/data?q=" + Uri.EscapeDataString(query); - if (signal != null) - queryUri += "&signal=" + Uri.EscapeDataString(signal); - - var request = new HttpRequestMessage - { - RequestUri = new Uri(queryUri, UriKind.Relative), - Method = HttpMethod.Post, Content = new StringContent("{}", new UTF8Encoding(false), "application/json") - }; - - var response = await connection.Client.HttpClient.SendAsync(request, cancellationToken); - - return Serializer.Deserialize( - new JsonTextReader(new StreamReader(await response.Content.ReadAsStreamAsync(cancellationToken))))!; - } -} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs index fb51e7e6..c8f19517 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs @@ -30,7 +30,6 @@ using Seq.Api.Model.Signals; using Seq.Syntax.Templates; using SeqCli.Mapping; -using SeqCli.Mcp.Data; using SeqCli.Output; using SeqCli.Signals; using Serilog; @@ -278,10 +277,14 @@ public async Task QueryAsync( "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true); } + SignalExpressionPart? parsedSignalExpression = null; + if (!string.IsNullOrWhiteSpace(signal)) + parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); + QueryResultPart result; try { - result = await DataResourceGroupHelper.QueryPreserveErrorResponsesAsync(connection, signal, query, cancellationToken); + result = await connection.Data.TryQueryAsync(query, signal: parsedSignalExpression, cancellationToken: cancellationToken); } catch (Exception ex) { @@ -291,35 +294,12 @@ public async Task QueryAsync( } 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}?" : "" - } - ] - }; + return SimpleTextResult($"The query failed. {error}", isError: true); } - + var output = new StringWriter(); NativeFormatter.WriteQueryResult(output, result); - return SimpleTextResult(output.ToString()); + return SimpleTextResult(output.ToString(), isError: !string.IsNullOrWhiteSpace(result.Error)); } [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] diff --git a/src/SeqCli/Templates/Export/TemplateSetExporter.cs b/src/SeqCli/Templates/Export/TemplateSetExporter.cs index 05c98d68..772ca43f 100644 --- a/src/SeqCli/Templates/Export/TemplateSetExporter.cs +++ b/src/SeqCli/Templates/Export/TemplateSetExporter.cs @@ -9,6 +9,7 @@ using Seq.Api.Model.Alerting; using Seq.Api.Model.Dashboarding; using Seq.Api.Model.Indexing; +using Seq.Api.Model.Metrics; using Seq.Api.Model.Retention; using Seq.Api.Model.Signals; using Seq.Api.Model.Queries; @@ -87,6 +88,12 @@ await ExportTemplates( ? expressionIndex.Expression : expressionIndex.Id.Replace("expressionindex-", ""), templateValueMap); + + await ExportTemplates( + id => _connection.Views.FindAsync(id), + () => _connection.Views.ListAsync(shared: true), + view => view.Title, + templateValueMap); } async Task ExportTemplates( diff --git a/src/SeqCli/Templates/Import/TemplateSetImporter.cs b/src/SeqCli/Templates/Import/TemplateSetImporter.cs index 9f14ec5c..85e94263 100644 --- a/src/SeqCli/Templates/Import/TemplateSetImporter.cs +++ b/src/SeqCli/Templates/Import/TemplateSetImporter.cs @@ -40,7 +40,7 @@ static class TemplateSetImporter { var ordering = new List {"users", "signals", "apps", "appinstances", "dashboards", "sqlqueries", "workspaces", "retentionpolicies", - "alerts", "expressionindexes"}; + "alerts", "expressionindexes", "views"}; var sorted = templates.OrderBy(t => ordering.IndexOf(t.ResourceGroup)); From 6cef52e65948902f27e2d5298d4639a5e373a34f Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 14:32:49 +1000 Subject: [PATCH 68/98] Refactor CSV writing --- src/SeqCli/Csv/CsvWriter.cs | 54 +++++++++++-------------------------- 1 file changed, 16 insertions(+), 38 deletions(-) diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index 1cf74241..e984ea05 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -21,51 +21,29 @@ public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, var first = true; QueryResultHelper.Flatten(result, row => { - if (first) + var firstCol = true; + foreach (var value in row) { - first = false; - var firstCol = true; - foreach (var heading in row) - { - if (firstCol) - { - firstCol = false; - } - else - { - theme.Set(output, ConsoleThemeStyle.TertiaryText); - output.Write(','); - theme.Reset(output); - } - - WriteCell(output, theme, heading, isHeadingRow: true); - } - } - else - { - var firstCol = true; - foreach (var value in row) - { - if (firstCol) - { - firstCol = false; - } - else - { - theme.Set(output, ConsoleThemeStyle.TertiaryText); - output.Write(','); - theme.Reset(output); - } - - WriteCell(output, theme, value); - } + WriteCell(output, theme, value, ref firstCol, isHeadingRow: first); } + first = false; output.WriteLine(); }); } - static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, bool isHeadingRow = false) + static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, ref bool firstCol, bool isHeadingRow = false) { + if (firstCol) + { + firstCol = false; + } + else + { + theme.Set(output, ConsoleThemeStyle.TertiaryText); + output.Write(','); + theme.Reset(output); + } + theme.Set(output, ConsoleThemeStyle.TertiaryText); output.Write('"'); theme.Reset(output); From 29e4983fed4e9ddd0946e834fa85c184939347d0 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 15:31:00 +1000 Subject: [PATCH 69/98] Nudge towards using signals in the skill --- src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md index ef25ffa4..3619c62a 100644 --- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md +++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md @@ -19,7 +19,7 @@ Being "confidently wrong" is the most common and worst failure mode when working session MUST begin with the following steps: 1. Check for relevant signals. Many event filtering problems have already been solved and the resulting filters saved as efficiently indexed signals. -2. Retrieve a sample of relevant events. +2. Retrieve a sample of relevant events. Use signals identified in (1) where appropriate, event search and query supports them. 3. Confirm the schema of the search results (important!). 4. Inspect a subset of relevant events in full. 5. Determine how the correctness of any conclusions can be verified using real diagnostic data. From a7afcb63a995cb21d2c2a0092d24a0bd38f185c4 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 15:56:14 +1000 Subject: [PATCH 70/98] Include sum in the sample Roastery histogram --- src/Roastery/Metrics/ExponentialHistogram.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Roastery/Metrics/ExponentialHistogram.cs b/src/Roastery/Metrics/ExponentialHistogram.cs index 8e3db4bf..c5d39b22 100644 --- a/src/Roastery/Metrics/ExponentialHistogram.cs +++ b/src/Roastery/Metrics/ExponentialHistogram.cs @@ -19,12 +19,14 @@ public ExponentialHistogram(int initialScale = 20, int targetBuckets = 160) double _min; double _max; + double _sum; ulong _total; public void Record(double rawValue) { _min = Math.Min(_min, rawValue); _max = Math.Max(_max, rawValue); + _sum += rawValue; _total += 1; var midpoint = Midpoint(_scale, rawValue); @@ -61,5 +63,6 @@ static double Midpoint(int scale, double rawValue) public double Min => _min; public double Max => _max; + public double Sum => _sum; public ulong Total => _total; -} +} \ No newline at end of file From 393aa905cef8af33ac1a21913754f87932a3c1cc Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Wed, 3 Jun 2026 16:00:25 +1000 Subject: [PATCH 71/98] NuGet package updates --- src/Roastery/Roastery.csproj | 2 +- src/SeqCli/SeqCli.csproj | 6 +++--- test/SeqCli.Tests/SeqCli.Tests.csproj | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Roastery/Roastery.csproj b/src/Roastery/Roastery.csproj index 3a9f8487..337aac00 100644 --- a/src/Roastery/Roastery.csproj +++ b/src/Roastery/Roastery.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index afd80598..4344de70 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -46,9 +46,9 @@ - - - + + + diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj index e1bc6937..da56b1d2 100644 --- a/test/SeqCli.Tests/SeqCli.Tests.csproj +++ b/test/SeqCli.Tests/SeqCli.Tests.csproj @@ -3,7 +3,7 @@ net10.0 - + all From 1921d1b335aade11db04d10ce9da7ad4762dfb9b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 05:35:20 +1000 Subject: [PATCH 72/98] Extend MCP and skills install commands to cover more agents and correct some existing ones. Assisted-by: Claude Opus 4.6 --- .../Cli/Commands/Skills/InstallCommand.cs | 4 +- src/SeqCli/Mcp/McpServerInstaller.cs | 85 +++++++++++++++++-- src/SeqCli/Skills/SkillInstaller.cs | 47 +++++++++- .../SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs | 78 +++++++++++++++-- .../Skills/SkillsInstallTestCase.cs | 29 +++++++ 5 files changed, 220 insertions(+), 23 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs index 8b6c634e..a0278f55 100644 --- a/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -29,9 +29,9 @@ public InstallCommand() { Options.Add( "g|global", - "Install skills globally, to `~/.{agent}/skills`; the default is to install locally, in `./{agent}/skills`", + "Install skills to the agent's user-level directory (e.g. `~/.{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`", diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index d9612102..7610010e 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -26,9 +26,11 @@ static class McpServerInstaller // 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. + // including the default `agents` name, Cursor, 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. Agents + // whose config is a format we can't safely edit (TOML/YAML) are listed via + // `Unsupported` so the user gets a copy-paste snippet instead of an ignored file. static readonly IReadOnlyDictionary KnownAgents = new Dictionary { @@ -40,20 +42,22 @@ static class McpServerInstaller : Path.Combine(Environment.CurrentDirectory, ".mcp.json"), "mcpServers"), - // Windsurf keeps a single user-global config under `~/.codeium`. + // Windsurf only reads a single user-global config under `~/.codeium`; it has + // no project-level MCP file, so a project install would be silently ignored. ["windsurf"] = new( global => global ? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json") - : Path.Combine(Environment.CurrentDirectory, ".windsurf", "mcp.json"), + : throw new NotSupportedException( + "Windsurf only supports a user-global MCP config; re-run with `--global` (seqcli mcp install --global --agent windsurf)."), "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/mcp.json`; the user-global equivalent is a `mcp.json` in the + // VS Code user directory (`%APPDATA%\Code\User` on Windows, `~/Library/ + // Application Support/Code/User` on macOS, `$XDG_CONFIG_HOME/Code/User` otherwise). ["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(VsCodeUserDir, "mcp.json") : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), "servers"), @@ -65,6 +69,51 @@ static class McpServerInstaller ".qwen", "settings.json"), "mcpServers"), + + // Gemini CLI mirrors Qwen Code: `mcpServers` inside `settings.json` under `.gemini`. + ["gemini"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".gemini", + "settings.json"), + "mcpServers"), + + // Zed embeds servers in its `settings.json` under a `context_servers` key + // (project `.zed/settings.json`; user-global `$XDG_CONFIG_HOME/zed/settings.json`). + ["zed"] = new( + global => global + ? Path.Combine(XdgConfigHome, "zed", "settings.json") + : Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"), + "context_servers"), + + // Amazon Q Developer CLI uses a standalone `mcp.json`: `.amazonq` per-project, + // but `~/.aws/amazonq` for the user-global file. + ["amazonq"] = new( + global => global + ? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json") + : Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"), + "mcpServers"), + + // Roo Code reads a project `.roo/mcp.json`; its user-global store lives in + // VS Code extension storage, whose path is publisher/platform-specific. + ["roo"] = new( + global => global + ? throw new NotSupportedException( + "Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead (seqcli mcp install --agent roo).") + : Path.Combine(Environment.CurrentDirectory, ".roo", "mcp.json"), + "mcpServers"), + + // Codex, Goose, and Continue store MCP config in TOML/YAML that seqcli can't + // safely edit, so we print the exact config to add by hand rather than writing + // a JSON file the agent would ignore. + ["codex"] = Unsupported( + "Codex reads MCP servers from ~/.codex/config.toml (TOML), which seqcli can't edit automatically. Add this block:\n\n[mcp_servers.seq]\ncommand = \"seqcli\"\nargs = [\"mcp\", \"run\"]"), + + ["goose"] = Unsupported( + "Goose reads MCP servers from ~/.config/goose/config.yaml (YAML) under `extensions`, which seqcli can't edit automatically. Add:\n\nextensions:\n seq:\n type: stdio\n cmd: seqcli\n args: [mcp, run]\n enabled: true"), + + ["continue"] = Unsupported( + "Continue reads MCP servers from YAML, which seqcli can't edit automatically. Create .continue/mcpServers/seq.yaml with:\n\nname: Seq\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: seq\n command: seqcli\n args:\n - mcp\n - run"), }; public static void Install(string? agent, bool global, string? profileName = null) @@ -104,6 +153,11 @@ public static void Install(string? agent, bool global, string? profileName = nul Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } + // For agents whose config format we can't write, resolving any path throws with a + // copy-paste snippet; the command runner turns this into a clean exit-1 message. + static AgentTarget Unsupported(string message) => + new(_ => throw new NotSupportedException(message), "mcpServers"); + static AgentTarget Convention(string agent) => new( global => Path.Combine( @@ -114,5 +168,18 @@ static AgentTarget Convention(string agent) => static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + static string XdgConfigHome => + Environment.GetEnvironmentVariable("XDG_CONFIG_HOME") is { Length: > 0 } configHome + ? configHome + : Path.Combine(UserProfile, ".config"); + + // VS Code keeps per-user data in an OS-specific directory. + static string VsCodeUserDir => + OperatingSystem.IsWindows() + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Code", "User") + : OperatingSystem.IsMacOS() + ? Path.Combine(UserProfile, "Library", "Application Support", "Code", "User") + : Path.Combine(XdgConfigHome, "Code", "User"); + sealed record AgentTarget(Func ResolvePath, string ServerMapKey); } diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 57b96f9c..2bc4188f 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Collections.Generic; using System.IO; using Serilog; @@ -20,14 +21,44 @@ namespace SeqCli.Skills; static class SkillInstaller { + // Agents whose skills directory diverges from the common `.{agent}/skills` convention. + // Anything not listed here - including the default `agents` name, Claude Code, Gemini CLI, + // Cursor, Junie, Kiro, and any unknown agent - uses the convention (see `Convention`), so + // a conformant agent requires no change at all and a divergent one is a single entry here. + static readonly IReadOnlyDictionary KnownAgents = + new Dictionary + { + // Codex reads skills only from `.agents/skills` (repo) and `~/.agents/skills` + // (user); it has no `.codex` skills dir, so route both scopes to the portable alias. + ["codex"] = new(global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".agents", + "skills")), + + // GitHub Copilot / VS Code read workspace skills from `.github/skills`, but the + // user-global personal skills dir is `~/.copilot/skills` - the namespace differs by scope. + ["copilot"] = new(global => global + ? Path.Combine(UserProfile, ".copilot", "skills") + : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + + // `github` is the workspace dir name a user may reach for; same targets as copilot. + ["github"] = new(global => global + ? Path.Combine(UserProfile, ".copilot", "skills") + : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + + // Goose reads a project `.goose/skills`, but its user-global skills live under the + // portable `~/.agents/skills` (not `~/.goose`). + ["goose"] = new(global => global + ? Path.Combine(UserProfile, ".agents", "skills") + : Path.Combine(Environment.CurrentDirectory, ".goose", "skills")), + }; + public static void Install(string? agent, bool global) { agent ??= "agents"; - var destinationPath = Path.Combine( - global ? UserProfile : Environment.CurrentDirectory, - $".{agent}", - "skills"); + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); + var destinationPath = target.ResolveSkillsDirectory(global); Log.Information("Installing skills to {SkillsPath}", destinationPath); @@ -44,6 +75,12 @@ public static void Install(string? agent, bool global) } } + static SkillTarget Convention(string agent) => + new(global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + $".{agent}", + "skills")); + static string UserProfile => Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); static void CopyFilesRecursive(string source, string destination) @@ -60,4 +97,6 @@ static void CopyFilesRecursive(string source, string destination) CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory))); } } + + sealed record SkillTarget(Func ResolveSkillsDirectory); } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs index a099e9c3..aa39ab3a 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs @@ -60,14 +60,76 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun 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); + // VS Code nests servers under a `servers` key in `.vscode/mcp.json`. + var vscodeExit = runner.Exec("mcp install -a vscode", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, vscodeExit); + + var vscodeConfig = File.ReadAllText(Path.Combine(tmp.Path, ".vscode/mcp.json")); + Assert.Contains("\"servers\"", vscodeConfig); + Assert.Contains("\"seq\"", vscodeConfig); + + // Gemini CLI reads `mcpServers` from `.gemini/settings.json`, not an `mcp.json`. + var geminiExit = runner.Exec("mcp install -a gemini", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, geminiExit); + + var geminiConfig = File.ReadAllText(Path.Combine(tmp.Path, ".gemini/settings.json")); + Assert.Contains("\"mcpServers\"", geminiConfig); + Assert.Contains("\"seq\"", geminiConfig); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".gemini/mcp.json"))); + + // Zed embeds servers under `context_servers` in `.zed/settings.json`. + var zedExit = runner.Exec("mcp install -a zed", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, zedExit); + + var zedConfig = File.ReadAllText(Path.Combine(tmp.Path, ".zed/settings.json")); + Assert.Contains("\"context_servers\"", zedConfig); + Assert.Contains("\"seq\"", zedConfig); + + // Amazon Q Developer CLI reads a project `.amazonq/mcp.json`. + var amazonqExit = runner.Exec("mcp install -a amazonq", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, amazonqExit); + + var amazonqConfig = File.ReadAllText(Path.Combine(tmp.Path, ".amazonq/mcp.json")); + Assert.Contains("\"mcpServers\"", amazonqConfig); + Assert.Contains("\"seq\"", amazonqConfig); + + // Roo Code reads a project `.roo/mcp.json`... + var rooExit = runner.Exec("mcp install -a roo", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, rooExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".roo/mcp.json"))); + + // ...but has no writable user-global target, so `--global` reports a clean error + // (and never leaks the exception type into the output). + var rooGlobalExit = runner.Exec("mcp install -a roo --global", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, rooGlobalExit); + + var rooGlobalOutput = runner.LastRunProcess!.Output; + Assert.Contains("extension storage", rooGlobalOutput); + Assert.DoesNotContain("NotSupportedException", rooGlobalOutput); + + // Windsurf is user-global only; a project install is rejected rather than writing + // an ignored `.windsurf/mcp.json`. + var windsurfExit = runner.Exec("mcp install -a windsurf", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, windsurfExit); + Assert.Contains("--global", runner.LastRunProcess!.Output); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".windsurf/mcp.json"))); + + // Codex/Goose/Continue use TOML/YAML config seqcli can't edit; instead of writing + // an ignored JSON file, the command prints a copy-paste snippet and fails. + var codexExit = runner.Exec("mcp install -a codex", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, codexExit); + Assert.Contains("config.toml", runner.LastRunProcess!.Output); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".codex"))); + + var gooseExit = runner.Exec("mcp install -a goose", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, gooseExit); + Assert.Contains("config.yaml", runner.LastRunProcess!.Output); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".goose"))); + + var continueExit = runner.Exec("mcp install -a continue", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(1, continueExit); + Assert.Contains("YAML", runner.LastRunProcess!.Output); + Assert.False(File.Exists(Path.Combine(tmp.Path, ".continue/mcp.json"))); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 831ad5b9..0a62752d 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -13,10 +13,39 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun { using var tmp = new TestDataFolder(); + // Convention fallback: an agent that isn't specially known installs into `.{agent}/skills`. 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"))); + // Conformant agents stay on the convention: Claude Code reads `.claude/skills`, and it + // refuses the portable `.agents` alias, so it must keep its own namespace. + var claudeExit = runner.Exec("skills install -a claude", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, claudeExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".claude/skills/seq-search-and-query/SKILL.md"))); + + // Codex has no `.codex` skills dir; its project skills live in the portable `.agents/skills`. + var codexExit = runner.Exec("skills install -a codex", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, codexExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".agents/skills/seq-search-and-query/SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".codex"))); + + // GitHub Copilot / VS Code read workspace skills from `.github/skills`, not `.copilot/skills`. + var copilotExit = runner.Exec("skills install -a copilot", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, copilotExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(tmp.Path, ".copilot"))); + + // `github` is an alias for the same Copilot workspace location. + var githubExit = runner.Exec("skills install -a github", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, githubExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md"))); + + // Goose reads a project `.goose/skills`. + var gooseExit = runner.Exec("skills install -a goose", disconnected: true, workingDirectory: tmp.Path); + Assert.Equal(0, gooseExit); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".goose/skills/seq-search-and-query/SKILL.md"))); + return Task.CompletedTask; } } \ No newline at end of file From e327c26c933186a89957d3c369374b2b6cbfcac3 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 09:27:21 +1000 Subject: [PATCH 73/98] Tidy up extended MCP/skill installers --- src/SeqCli/Mcp/McpServerInstaller.cs | 49 +++++++------------ src/SeqCli/Skills/SkillInstaller.cs | 33 ++++--------- test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj | 1 + .../Skills/SkillsInstallTestCase.cs | 8 +-- test/SeqCli.EndToEnd/Support/ICliTestCase.cs | 4 +- 5 files changed, 36 insertions(+), 59 deletions(-) diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 7610010e..18e8f6a0 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -24,45 +24,35 @@ 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, Cursor, 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. Agents - // whose config is a format we can't safely edit (TOML/YAML) are listed via - // `Unsupported` so the user gets a copy-paste snippet instead of an ignored file. 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 only reads a single user-global config under `~/.codeium`; it has - // no project-level MCP file, so a project install would be silently ignored. ["windsurf"] = new( global => global ? Path.Combine(UserProfile, ".codeium", "windsurf", "mcp_config.json") : throw new NotSupportedException( - "Windsurf only supports a user-global MCP config; re-run with `--global` (seqcli mcp install --global --agent windsurf)."), + "Windsurf only supports a user-global MCP config; re-run with `--global`."), "mcpServers"), - // VS Code nests servers under a `servers` key. Project config lives in - // `.vscode/mcp.json`; the user-global equivalent is a `mcp.json` in the - // VS Code user directory (`%APPDATA%\Code\User` on Windows, `~/Library/ - // Application Support/Code/User` on macOS, `$XDG_CONFIG_HOME/Code/User` otherwise). ["vscode"] = new( global => global ? Path.Combine(VsCodeUserDir, "mcp.json") : Path.Combine(Environment.CurrentDirectory, ".vscode", "mcp.json"), "servers"), + + ["copilot"] = new( + global => global + ? Path.Combine(UserProfile, ".copilot", "mcp-config.json") + : throw new NotSupportedException( + "GitHub Copilot only supports a user-global MCP config; re-run with `--global`."), + "mcpServers"), - // 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, @@ -70,7 +60,6 @@ static class McpServerInstaller "settings.json"), "mcpServers"), - // Gemini CLI mirrors Qwen Code: `mcpServers` inside `settings.json` under `.gemini`. ["gemini"] = new( global => Path.Combine( global ? UserProfile : Environment.CurrentDirectory, @@ -78,34 +67,25 @@ static class McpServerInstaller "settings.json"), "mcpServers"), - // Zed embeds servers in its `settings.json` under a `context_servers` key - // (project `.zed/settings.json`; user-global `$XDG_CONFIG_HOME/zed/settings.json`). ["zed"] = new( global => global ? Path.Combine(XdgConfigHome, "zed", "settings.json") : Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"), "context_servers"), - // Amazon Q Developer CLI uses a standalone `mcp.json`: `.amazonq` per-project, - // but `~/.aws/amazonq` for the user-global file. ["amazonq"] = new( global => global ? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json") : Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"), "mcpServers"), - // Roo Code reads a project `.roo/mcp.json`; its user-global store lives in - // VS Code extension storage, whose path is publisher/platform-specific. ["roo"] = new( global => global ? throw new NotSupportedException( - "Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead (seqcli mcp install --agent roo).") + "Roo Code stores user-global MCP servers in VS Code extension storage; install into a project instead.") : Path.Combine(Environment.CurrentDirectory, ".roo", "mcp.json"), "mcpServers"), - // Codex, Goose, and Continue store MCP config in TOML/YAML that seqcli can't - // safely edit, so we print the exact config to add by hand rather than writing - // a JSON file the agent would ignore. ["codex"] = Unsupported( "Codex reads MCP servers from ~/.codex/config.toml (TOML), which seqcli can't edit automatically. Add this block:\n\n[mcp_servers.seq]\ncommand = \"seqcli\"\nargs = [\"mcp\", \"run\"]"), @@ -115,11 +95,20 @@ static class McpServerInstaller ["continue"] = Unsupported( "Continue reads MCP servers from YAML, which seqcli can't edit automatically. Create .continue/mcpServers/seq.yaml with:\n\nname: Seq\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: seq\n command: seqcli\n args:\n - mcp\n - run"), }; + + static readonly IReadOnlyDictionary AgentAliases = + new Dictionary + { + ["github"] = "copilot" + }; public static void Install(string? agent, bool global, string? profileName = null) { agent ??= "agents"; + if (AgentAliases.TryGetValue(agent, out var alias)) + agent = alias; + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); var path = target.ResolvePath(global); @@ -153,8 +142,6 @@ public static void Install(string? agent, bool global, string? profileName = nul Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } - // For agents whose config format we can't write, resolving any path throws with a - // copy-paste snippet; the command runner turns this into a clean exit-1 message. static AgentTarget Unsupported(string message) => new(_ => throw new NotSupportedException(message), "mcpServers"); diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 2bc4188f..2f62ede4 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -21,42 +21,29 @@ namespace SeqCli.Skills; static class SkillInstaller { - // Agents whose skills directory diverges from the common `.{agent}/skills` convention. - // Anything not listed here - including the default `agents` name, Claude Code, Gemini CLI, - // Cursor, Junie, Kiro, and any unknown agent - uses the convention (see `Convention`), so - // a conformant agent requires no change at all and a divergent one is a single entry here. static readonly IReadOnlyDictionary KnownAgents = new Dictionary { - // Codex reads skills only from `.agents/skills` (repo) and `~/.agents/skills` - // (user); it has no `.codex` skills dir, so route both scopes to the portable alias. - ["codex"] = new(global => Path.Combine( - global ? UserProfile : Environment.CurrentDirectory, - ".agents", - "skills")), - - // GitHub Copilot / VS Code read workspace skills from `.github/skills`, but the - // user-global personal skills dir is `~/.copilot/skills` - the namespace differs by scope. ["copilot"] = new(global => global ? Path.Combine(UserProfile, ".copilot", "skills") : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), + }; - // `github` is the workspace dir name a user may reach for; same targets as copilot. - ["github"] = new(global => global - ? Path.Combine(UserProfile, ".copilot", "skills") - : Path.Combine(Environment.CurrentDirectory, ".github", "skills")), - - // Goose reads a project `.goose/skills`, but its user-global skills live under the - // portable `~/.agents/skills` (not `~/.goose`). - ["goose"] = new(global => global - ? Path.Combine(UserProfile, ".agents", "skills") - : Path.Combine(Environment.CurrentDirectory, ".goose", "skills")), + static readonly IReadOnlyDictionary AgentAliases = + new Dictionary + { + ["goose"] = "agents", + ["github"] = "copilot", + ["codex"] = "agents" }; public static void Install(string? agent, bool global) { agent ??= "agents"; + if (AgentAliases.TryGetValue(agent, out var alias)) + agent = alias; + var target = KnownAgents.TryGetValue(agent, out var known) ? known : Convention(agent); var destinationPath = target.ResolveSkillsDirectory(global); diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj index 33dd29e3..988cbea1 100644 --- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj +++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj @@ -7,6 +7,7 @@ false + diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs index 0a62752d..88871bc1 100644 --- a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs +++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs @@ -1,5 +1,6 @@ using System.IO; using System.Threading.Tasks; +using JetBrains.Annotations; using Seq.Api; using SeqCli.EndToEnd.Support; using Serilog; @@ -18,8 +19,7 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Equal(0, exit); Assert.True(File.Exists(Path.Combine(tmp.Path, ".test-agent/skills/seq-search-and-query/SKILL.md"))); - // Conformant agents stay on the convention: Claude Code reads `.claude/skills`, and it - // refuses the portable `.agents` alias, so it must keep its own namespace. + // Claude Code reads `.claude/skills`, and refuses the portable `.agents` alias, so it must keep its own namespace. var claudeExit = runner.Exec("skills install -a claude", disconnected: true, workingDirectory: tmp.Path); Assert.Equal(0, claudeExit); Assert.True(File.Exists(Path.Combine(tmp.Path, ".claude/skills/seq-search-and-query/SKILL.md"))); @@ -41,10 +41,10 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun Assert.Equal(0, githubExit); Assert.True(File.Exists(Path.Combine(tmp.Path, ".github/skills/seq-search-and-query/SKILL.md"))); - // Goose reads a project `.goose/skills`. + // Goose uses the `agents` convention. var gooseExit = runner.Exec("skills install -a goose", disconnected: true, workingDirectory: tmp.Path); Assert.Equal(0, gooseExit); - Assert.True(File.Exists(Path.Combine(tmp.Path, ".goose/skills/seq-search-and-query/SKILL.md"))); + Assert.True(File.Exists(Path.Combine(tmp.Path, ".agents/skills/seq-search-and-query/SKILL.md"))); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/Support/ICliTestCase.cs b/test/SeqCli.EndToEnd/Support/ICliTestCase.cs index 5697c2b8..849bb24f 100644 --- a/test/SeqCli.EndToEnd/Support/ICliTestCase.cs +++ b/test/SeqCli.EndToEnd/Support/ICliTestCase.cs @@ -1,10 +1,12 @@ using System.Threading.Tasks; +using JetBrains.Annotations; using Seq.Api; using Serilog; namespace SeqCli.EndToEnd.Support; +[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] interface ICliTestCase { Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner); -} \ No newline at end of file +} From 67c8d74d41743c84cdb2bc2808a6458a5822f532 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 09:35:46 +1000 Subject: [PATCH 74/98] A little more feedback for the user --- src/SeqCli/Mcp/McpServerInstaller.cs | 4 ++++ src/SeqCli/Skills/SkillInstaller.cs | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 18e8f6a0..52bc4596 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -136,9 +136,13 @@ public static void Install(string? agent, bool global, string? profileName = nul ["args"] = args, }; + Console.Write("Installing MCP server to `{0}`...", path); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, root.ToString(Newtonsoft.Json.Formatting.Indented)); + Console.WriteLine(" Done."); + Log.Information("Installed Seq MCP server for {Agent} to {Path}", agent, path); } diff --git a/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs index 2f62ede4..eec37480 100644 --- a/src/SeqCli/Skills/SkillInstaller.cs +++ b/src/SeqCli/Skills/SkillInstaller.cs @@ -56,9 +56,11 @@ public static void Install(string? agent, bool global) var skillName = Path.GetFileName(skillSourceDirectory); var destination = Path.Combine(destinationPath, skillName); - Log.Information("Installing skill {SkillName} to destination path {SkillPath}", skillName, destinationPath); + Console.Write("Installing skill `{0}` to `{1}`...", skillName, destinationPath); CopyFilesRecursive(skillSourceDirectory, destination); + + Console.WriteLine(" Done."); } } From 98dc158b64d536d74d3b4ecddc2d68358ca35cdd Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Thu, 4 Jun 2026 09:57:26 +1000 Subject: [PATCH 75/98] README updates to point out preview MCP/agent skills --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index e24a0b7f..b855a58f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,28 @@ $token = ( ) ``` +### MCP and agent skills (preview) + +The 2026.1 preview improves support for agent-driven diagnostics workflows: + +``` +dotnet tool install -g seqcli --prerelease +``` + +For skill installation: + +``` +seqcli skills install -a [--global] +``` + +For local MCP server installation: + +``` +seqcli mcp install -a [--global] +``` + +Credentials are set using configuration and environment variables as described above. + ## Contributing See `CONTRIBUTING.md`. From fb9aef677826c2105055ebef3fbec4914ebe4eef Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 13:01:25 +1000 Subject: [PATCH 76/98] Update `dashboard render` to accept `--native` --- src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs | 4 ++-- src/SeqCli/Output/NativeFormatter.cs | 1 - test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index fb3cac9a..bbbd7f59 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -58,7 +58,7 @@ public RenderCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); _connection = Enable(); } @@ -191,7 +191,7 @@ static string BuildSqlQuery(ChartQueryPart query, DateTime rangeStart, DateTime static SignalExpressionPart? Intersect(params SignalExpressionPart?[] expressions) { - var result = (SignalExpressionPart?) null; + SignalExpressionPart? result = null; foreach (var s in expressions) { diff --git a/src/SeqCli/Output/NativeFormatter.cs b/src/SeqCli/Output/NativeFormatter.cs index 782a2433..79017ce5 100644 --- a/src/SeqCli/Output/NativeFormatter.cs +++ b/src/SeqCli/Output/NativeFormatter.cs @@ -255,7 +255,6 @@ public static void WriteQueryResult(TextWriter output, QueryResultPart result) output.Write(' '); output.Write(heading); } - output.WriteLine(); } else { diff --git a/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs b/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs index e7baad65..c1810319 100644 --- a/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs +++ b/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs @@ -18,7 +18,7 @@ public Task ExecuteAsync( var exit = runner.Exec("dashboard list"); Assert.Equal(0, exit); - var id = runner.LastRunProcess.Output.Split(' ')[0]; + var id = runner.LastRunProcess!.Output.Split(' ')[0]; exit = runner.Exec("dashboard render", $"-i {id} -c \"All Events\" --last 1d --by 1h --no-color"); Assert.Equal(0, exit); From f88b4463a15123ffa5f426d3d6f610f09cf28aca Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 14:59:54 +1000 Subject: [PATCH 77/98] Start work on metrics command group --- .../Cli/Commands/Metrics/DimensionCommand.cs | 6 + .../Cli/Commands/Metrics/DimensionsCommand.cs | 6 + .../Cli/Commands/Metrics/SearchCommand.cs | 124 ++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs create mode 100644 src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs new file mode 100644 index 00000000..71de1d25 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs @@ -0,0 +1,6 @@ +namespace SeqCli.Cli.Commands.Metrics; + +class DimensionCommand +{ + +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs new file mode 100644 index 00000000..9190e8ae --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -0,0 +1,6 @@ +namespace SeqCli.Cli.Commands.Metrics; + +class DimensionsCommand +{ + +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs new file mode 100644 index 00000000..3f91ad7b --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs @@ -0,0 +1,124 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api.Model.Data; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Util; +using Serilog; + +namespace SeqCli.Cli.Commands.Metrics; + +[Command("metrics", "search", "List available metric definitions", + Example = "seqcli metrics search -f \"@Resource.service.name = 'proxy'\" -c 100")] +class SearchCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _filter; + readonly List _groups = []; + int _count = 1; + bool _trace; + + public SearchCommand() + { + Options.Add( + "f=|filter=", + "A filter to apply to the search, including metric name/description text in double quotes, for example `\"cpu\" and Host = 'xmpweb-01.example.com'`", + v => _filter = v); + + Options.Add( + "g=|group=", + "Group key for metric definition breakdown; this argument can be used multiple times", + c => _groups.Add(ArgumentString.Normalize(c) ?? throw new ArgumentException("Group keys require a value."))); + + Options.Add( + "c=|count=", + $"The maximum number of metric definitions to retrieve; the default is {_count}", + v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + + _range = Enable(); + // Native is not supported because accessor expressions appear in the output, and the escaping applied to them + // as native strings does more harm than good. + _output = Enable(); + _storagePath = Enable(); + + Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); + + string? filter = null; + if (!string.IsNullOrWhiteSpace(_filter)) + filter = (await connection.Expressions.ToStrictAsync(_filter)).StrictExpression; + + var result = await connection.Metrics.SearchAsync( + _groups, + filter, + _count, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + // We convert the metric into a query result to improve formatting consistency. Room for an abstraction of + // some kind here. + var rows = new List(); + foreach (var metric in result.Metrics) + { + var row = new List + { + metric.Accessor, + metric.Kind, + metric.Unit, + metric.Description + }; + + foreach (var value in metric.GroupKey) + row.Add(value); + + rows.Add(row.ToArray()); + } + var asRowset = new QueryResultPart + { + Columns = new[] { "Accessor", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), + Rows = rows.ToArray() + }; + + output.WriteQueryResult(asRowset); + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); + return 1; + } + } +} \ No newline at end of file From d3e2e600b62b19fd5c7df3a1990a0bc87ad94e32 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 15:15:21 +1000 Subject: [PATCH 78/98] Dimensions command --- .../Cli/Commands/Metrics/DimensionsCommand.cs | 91 ++++++++++++++++++- .../Cli/Commands/Metrics/SearchCommand.cs | 8 +- src/SeqCli/Output/OutputFormat.cs | 19 ++-- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 9190e8ae..99e2347e 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -1,6 +1,93 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + namespace SeqCli.Cli.Commands.Metrics; -class DimensionsCommand +[Command("metrics", "dimensions", "List the dimensions that apply to a given metric", + Example = "seqcli metrics dimensions -m http.response.status_code")] +class DimensionsCommand : Command { - + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _metric; + int _count = 30; + bool _trace; + + public DimensionsCommand() + { + Options.Add( + "m=|metric=", + "The metric name, for example `hats-sold` or `http.request.duration`", + v => _metric= v); + + Options.Add( + "c=|count=", + $"The maximum number of dimensions to retrieve; the default is {_count}", + v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + + _range = Enable(); + _output = Enable(); + _storagePath = Enable(); + + Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var result = await connection.Metrics.ListDimensionsAsync( + _count, + _metric, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + if (output.Json) + { + output.WriteObject(result); + } + else + { + foreach (var dimension in result) + { + output.WriteText(dimension.Accessor); + } + } + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); + return 1; + } + } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs index 3f91ad7b..5cc1c3ee 100644 --- a/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/SearchCommand.cs @@ -27,7 +27,7 @@ namespace SeqCli.Cli.Commands.Metrics; [Command("metrics", "search", "List available metric definitions", - Example = "seqcli metrics search -f \"@Resource.service.name = 'proxy'\" -c 100")] + Example = "seqcli metrics search -f \"@Resource.service.name = 'proxy'\" -c 512")] class SearchCommand : Command { readonly ConnectionFeature _connection; @@ -36,7 +36,7 @@ class SearchCommand : Command readonly StoragePathFeature _storagePath; string? _filter; readonly List _groups = []; - int _count = 1; + int _count = 30; bool _trace; public SearchCommand() @@ -94,7 +94,7 @@ protected override async Task Run() { var row = new List { - metric.Accessor, + metric.Name ?? metric.Accessor, metric.Kind, metric.Unit, metric.Description @@ -107,7 +107,7 @@ protected override async Task Run() } var asRowset = new QueryResultPart { - Columns = new[] { "Accessor", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), + Columns = new[] { "Name", "Kind", "Unit", "Description" }.Concat(_groups).ToArray(), Rows = rows.ToArray() }; diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index 0e7c47d7..fe35f693 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -13,6 +13,7 @@ // limitations under the License. using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -146,14 +147,16 @@ public void WriteObject(object value) if (Json) { - var jo = JObject.FromObject( - value, - JsonSerializer.CreateDefault(new JsonSerializerSettings { - DateParseHandling = DateParseHandling.None, - Converters = { - new StringEnumConverter() - } - })); + var settings = JsonSerializer.CreateDefault(new JsonSerializerSettings + { + DateParseHandling = DateParseHandling.None, + Converters = + { + new StringEnumConverter() + } + }); + + var jo = value is ICollection and not IDictionary ? (JToken)JArray.FromObject(value, settings) : JObject.FromObject(value, settings); // Using the same method of JSON colorization as above From 2fe4cffd19cf14c92ae4f1b296c06865bdc85266 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 16:47:13 +1000 Subject: [PATCH 79/98] Dimension (values) command --- .../Cli/Commands/Metrics/DimensionCommand.cs | 100 +++++++++++++++++- .../Cli/Commands/Metrics/DimensionsCommand.cs | 8 +- src/SeqCli/Csv/CsvWriter.cs | 21 +--- src/SeqCli/Output/OutputFormat.cs | 25 ++++- 4 files changed, 131 insertions(+), 23 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs index 71de1d25..f3296992 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs @@ -1,6 +1,102 @@ +// Copyright © Datalust Pty Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Globalization; +using System.Threading.Tasks; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using Serilog; + namespace SeqCli.Cli.Commands.Metrics; -class DimensionCommand +[Command("metrics", "dimension", "List distinct values for a metric dimension", + Example = "seqcli metrics dimension --accessor @Resource.service.name")] +class DimensionCommand : Command { - + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _accessor; + int _count = 30; + bool _trace; + + public DimensionCommand() + { + Options.Add( + "d=|accessor=", + "The dimension accessor, e.g. `cpu.mode`", + v => _accessor= v); + + Options.Add( + "c=|count=", + $"The maximum number of dimensions to retrieve; the default is {_count}", + v => _count = int.Parse(v, CultureInfo.InvariantCulture)); + + _range = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); + _storagePath = Enable(); + + Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); + + _connection = Enable(); + } + + protected override async Task Run() + { + try + { + if (string.IsNullOrWhiteSpace(_accessor)) + { + Log.Error("A dimension `--accessor` must be specified"); + return 1; + } + + var config = RuntimeConfigurationLoader.Load(_storagePath); + var output = _output.GetOutputFormat(config); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var result = await connection.Metrics.ListDimensionValuesAsync( + _accessor, + _count, + rangeStartUtc: _range.Start, + rangeEndUtc: _range.End, + trace: _trace); + + if (output.Json) + { + // In the JSON case we write an array with all values. + output.WriteObject(result); + } + else + { + // Native and plain text formatting use one-per-line output (both allow multi-line strings, but + // string boundaries are clearer in native mode). + foreach (var value in result) + { + output.WriteObject(value); + } + } + + return 0; + } + catch (Exception ex) + { + Log.Error(ex, "Could not retrieve metrics: {ErrorMessage}", ex.Message); + return 1; + } + } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 99e2347e..51cb9297 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -22,7 +22,7 @@ namespace SeqCli.Cli.Commands.Metrics; -[Command("metrics", "dimensions", "List the dimensions that apply to a given metric", +[Command("metrics", "dimensions", "List the dimensions associated with a given metric", Example = "seqcli metrics dimensions -m http.response.status_code")] class DimensionsCommand : Command { @@ -59,6 +59,12 @@ protected override async Task Run() { try { + if (string.IsNullOrWhiteSpace(_metric)) + { + Log.Error("A `--metric` must be specified"); + return 1; + } + var config = RuntimeConfigurationLoader.Load(_storagePath); var output = _output.GetOutputFormat(config); var connection = SeqConnectionFactory.Connect(_connection, config); diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index e984ea05..fff15271 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -1,5 +1,4 @@ using System; -using System.Globalization; using System.IO; using Seq.Api.Model.Data; using SeqCli.Mcp.Data; @@ -9,7 +8,7 @@ namespace SeqCli.Csv; static class CsvWriter { - public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, TextWriter output) + public static void WriteQueryResult(QueryResultPart result, Func stringify, ConsoleTheme theme, TextWriter output) { if (!string.IsNullOrWhiteSpace(result.Error)) { @@ -24,14 +23,14 @@ public static void WriteQueryResult(QueryResultPart result, ConsoleTheme theme, var firstCol = true; foreach (var value in row) { - WriteCell(output, theme, value, ref firstCol, isHeadingRow: first); + WriteCell(output, theme, value, stringify, ref firstCol, isHeadingRow: first); } first = false; output.WriteLine(); }); } - static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, ref bool firstCol, bool isHeadingRow = false) + static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, Func stringify, ref bool firstCol, bool isHeadingRow = false) { if (firstCol) { @@ -48,19 +47,7 @@ static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, ref output.Write('"'); theme.Reset(output); - var valueAsString = value switch - { - null => "null", - true => "true", - false => "false", - 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 => ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture), - DateTime dt => dt.ToString("o"), - DateTimeOffset dto => dto.ToString("o"), - _ => value.ToString() ?? "" - }; + var valueAsString = stringify(value); var dataStyle = isHeadingRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text; var doubleQuote = valueAsString.IndexOf('"'); diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index fe35f693..f35fcb6b 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -172,11 +172,12 @@ public void WriteObject(object value) } else if (Text) { - Console.WriteLine(value.ToString()); + Console.WriteLine(Stringify(value)); } else { - throw new InvalidOperationException("Native formatting not supported for raw objects."); + NativeFormatter.WriteValue(Console.Out, value); + Console.WriteLine(); } } @@ -208,7 +209,7 @@ public void WriteQueryResult(QueryResultPart result) } else { - CsvWriter.WriteQueryResult(result, Theme, Console.Out); + CsvWriter.WriteQueryResult(result, Stringify, Theme, Console.Out); } } @@ -297,4 +298,22 @@ static LogEventPropertyValue CreatePropertyValue(object value) return new ScalarValue(value); } } + + static string Stringify(object? value) + { + return value switch + { + null => "null", + true => "true", + false => "false", + 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 => ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture), + DateTime dt => dt.ToString("o"), + DateTimeOffset dto => dto.ToString("o"), + _ => value.ToString() ?? "" + }; + } } From e51e65ee8b7a8ca7fa3fbb7643b2705ed9acccbe Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Fri, 5 Jun 2026 17:30:54 +1000 Subject: [PATCH 80/98] Metrics CLI tests --- .../Cli/Commands/Metrics/DimensionsCommand.cs | 6 -- src/SeqCli/Cli/Commands/Node/HealthCommand.cs | 7 +- src/SeqCli/Output/OutputFormat.cs | 4 +- .../Metrics/MetricsCliBasics.cs | 74 +++++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 51cb9297..89896a32 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -59,12 +59,6 @@ protected override async Task Run() { try { - if (string.IsNullOrWhiteSpace(_metric)) - { - Log.Error("A `--metric` must be specified"); - return 1; - } - var config = RuntimeConfigurationLoader.Load(_storagePath); var output = _output.GetOutputFormat(config); var connection = SeqConnectionFactory.Connect(_connection, config); diff --git a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs index 6cabd844..54bcf7a9 100644 --- a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs +++ b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs @@ -108,15 +108,16 @@ async Task RunOnce(SeqConnection connection, OutputFormat outputFormat) if (outputFormat.Json) { var shouldBeJson = await response.Content.ReadAsStringAsync(); + object obj; try { - var obj = JsonConvert.DeserializeObject(shouldBeJson) ?? throw new InvalidDataException(); - outputFormat.WriteObject(obj); + obj = JsonConvert.DeserializeObject(shouldBeJson) ?? throw new InvalidDataException(); } catch { - outputFormat.WriteObject(new { Response = shouldBeJson }); + obj = new { Response = shouldBeJson }; } + outputFormat.WriteObject(obj); } else { diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index f35fcb6b..dbad8667 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -156,7 +156,9 @@ public void WriteObject(object value) } }); - var jo = value is ICollection and not IDictionary ? (JToken)JArray.FromObject(value, settings) : JObject.FromObject(value, settings); + var jo = value is ICollection and not (IDictionary or JToken) ? + (JToken)JArray.FromObject(value, settings) : + JObject.FromObject(value, settings); // Using the same method of JSON colorization as above diff --git a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs new file mode 100644 index 00000000..16adf5f5 --- /dev/null +++ b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Metrics; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +class MetricsCliBasics: ICliTestCase +{ + public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) + { + await IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); + await IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); + await IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); + + Assert.Equal(2, SearchResultLines(runner).Count()); + Assert.Equal(4, SearchResultLines(runner, groups: ["b"]).Count()); + Assert.Equal(3, SearchResultLines(runner, filter: "\"xyz\"", groups: ["b"]).Count()); + + Assert.Equal(0, runner.Exec("metrics dimensions")); + var allDimensions = runner.LastRunProcess!.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + Assert.All(["b", "c", "d"], name => Assert.Contains(allDimensions, l => l.Trim() == name)); + + Assert.Equal(0, runner.Exec("metrics dimensions", "--metric e")); + Assert.Equal("b", runner.LastRunProcess!.Output.Trim()); + + Assert.Equal(0, runner.Exec("metrics dimension", "--accessor b")); + var bValues = runner.LastRunProcess!.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + Assert.All(["2", "5"], value => Assert.Contains(bValues, l => l.Trim() == value)); + } + + static IEnumerable SearchResultLines(CliCommandRunner runner, string? filter = null, string[]? groups = null) + { + var args = ""; + if (filter != null) + args += $"--filter=\"{filter.Replace("\"", "\\\"")}\""; + foreach (var group in groups ?? []) + args += $" --group=\"{group.Replace("\"", "\\\"")}\""; + + Assert.Equal(0, runner.Exec("metrics search", args)); + var reader = new StringReader(runner.LastRunProcess!.Output); + var skippedHeading = false; + while (reader.ReadLine() is { } line) + { + if (!skippedHeading) + { + skippedHeading = true; + } + else + { + yield return line!; + } + } + } + + static async Task IngestClef(SeqConnection connection, string fields) + { + var prefix = $"{{\"@t\":\"{DateTime.UtcNow:o}\","; + const string suffix = "}"; + var content = new StringContent($"{prefix}{fields.Replace("'", "\"")}{suffix}"); + var response = await connection.Client.HttpClient.PostAsync("ingest/clef", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } +} \ No newline at end of file From bf8b46a251fe575846bacfc623d3962142b4ebb8 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 7 Jun 2026 13:55:10 +1000 Subject: [PATCH 81/98] Metrics MCP tools --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 8 +- .../Cli/Commands/Metrics/DimensionsCommand.cs | 2 +- src/SeqCli/Mcp/McpSession.cs | 2 + src/SeqCli/Mcp/Tools/McpResults.cs | 35 ++++ .../Mcp/Tools/Metrics/MetricDefinition.cs | 37 ++++ src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs | 134 +++++++++++++++ src/SeqCli/Mcp/Tools/Query/QueryTools.cs | 83 +++++++++ ...archAndQueryToolType.cs => SearchTools.cs} | 161 +++--------------- .../{Search => Signals}/SignalSummary.cs | 2 +- src/SeqCli/Mcp/Tools/Signals/SignalTools.cs | 38 +++++ .../Mcp/McpSessionBasicsTestCase.cs | 2 +- .../Mcp/McpSignalUsageTestCase.cs | 3 +- 12 files changed, 368 insertions(+), 139 deletions(-) create mode 100644 src/SeqCli/Mcp/Tools/McpResults.cs create mode 100644 src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs create mode 100644 src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs create mode 100644 src/SeqCli/Mcp/Tools/Query/QueryTools.cs rename src/SeqCli/Mcp/Tools/Search/{SearchAndQueryToolType.cs => SearchTools.cs} (60%) rename src/SeqCli/Mcp/Tools/{Search => Signals}/SignalSummary.cs (96%) create mode 100644 src/SeqCli/Mcp/Tools/Signals/SignalTools.cs diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 88d3ab8c..3dd9fbca 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -21,7 +21,10 @@ using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Mcp; +using SeqCli.Mcp.Tools.Metrics; +using SeqCli.Mcp.Tools.Query; using SeqCli.Mcp.Tools.Search; +using SeqCli.Mcp.Tools.Signals; using Serilog; namespace SeqCli.Cli.Commands.Mcp; @@ -66,7 +69,10 @@ protected override async Task Run() .AddMcpServer() .WithStdioServerTransport() .WithTools([ - typeof(SearchAndQueryToolType) + typeof(SearchTools), + typeof(MetricsTools), + typeof(QueryTools), + typeof(SignalTools) ]); await builder.Build().RunAsync(); diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs index 89896a32..9f085c28 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -38,7 +38,7 @@ public DimensionsCommand() { Options.Add( "m=|metric=", - "The metric name, for example `hats-sold` or `http.request.duration`", + "A metric name, for example `hats-sold` or `http.request.duration`; omit to list dimensions for all metrics", v => _metric= v); Options.Add( diff --git a/src/SeqCli/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs index 02606f68..8a102b32 100644 --- a/src/SeqCli/Mcp/McpSession.cs +++ b/src/SeqCli/Mcp/McpSession.cs @@ -25,6 +25,8 @@ namespace SeqCli.Mcp; class McpSession { + public TimeSpan DataToolCallTimeout { get; } = TimeSpan.FromSeconds(45); + readonly Lock _sync = new(); int _nextId = 1; readonly Dictionary _resultIdToEventId = new(); diff --git a/src/SeqCli/Mcp/Tools/McpResults.cs b/src/SeqCli/Mcp/Tools/McpResults.cs new file mode 100644 index 00000000..854b63bc --- /dev/null +++ b/src/SeqCli/Mcp/Tools/McpResults.cs @@ -0,0 +1,35 @@ +// 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 ModelContextProtocol.Protocol; + +namespace SeqCli.Mcp.Tools; + +static class McpResults +{ + public static CallToolResult SimpleText(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/Mcp/Tools/Metrics/MetricDefinition.cs b/src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs new file mode 100644 index 00000000..fe53e115 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Metrics/MetricDefinition.cs @@ -0,0 +1,37 @@ +// 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.ComponentModel; + +namespace SeqCli.Mcp.Tools.Metrics; + +[Description("Describes a metric.")] +class MetricDefinition +{ + [Description("The metric name.")] + public required string Name { get; init; } + + [Description("The metric kind; normally one of `Sum` (counters with delta temporality), `Gauge`, `Fixed` (fixed-bucket histogram), or `Exponential` (exponentially-bucketed histogram).")] + public required string Kind { get; init; } + + [Description("The UCUM unit specified for the metric, if any.")] + public string? Unit { get; init; } + + [Description("A human-readable description of the metric, if available.")] + public string? Description { get; init; } + + [Description("If group keys were specified when retrieving the metric, the values of those group keys for this definition.")] + public List GroupKeyValues { get; init; } = []; +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs b/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs new file mode 100644 index 00000000..160978df --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs @@ -0,0 +1,134 @@ +// 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.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Seq.Api; +using SeqCli.Output; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Metrics; + +[McpServerToolType] +class MetricsTools(McpSession session, SeqConnection connection) +{ + [McpServerTool(Name = "seq_search_metric_definitions", ReadOnly = true, Title = "Search Metric Definitions", UseStructuredContent = true)] + [Description("Search for metric definitions matching given criteria.")] + [return: Description("Matching metric definitions.")] + public async Task SearchMetricsAsync( + [Description("The maximum number of metric definitions to return.")] + [Range(1, 1000)] + int limit, + [Description("A Seq search expression evaluated over metric names (`Keys(@Definitions)[?]`), descriptions " + + "(`@Definitions[?].description`), resource attributes, scope attributes, and raw samples.")] + string? predicate = null, + [Description("Optionally, break down the available descriptions by grouping on one or more resource, scope, or " + + "sample attributes.")] + string[]? groups = null, + CancellationToken cancellationToken = default) + { + if (!string.IsNullOrWhiteSpace(predicate)) + { + if (!predicate.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase)) + { + throw new McpException( + "The predicate doesn't adequately constrain the search range. " + + "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 4h`."); + } + + var strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); + if (strict.MatchedAsText) + { + throw new McpException( + $"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}"); + } + } + + var definitions = await connection.Metrics.SearchAsync(groups?.ToList() ?? [], predicate, limit, + timeout: session.DataToolCallTimeout, cancellationToken: cancellationToken); + + return definitions.Metrics.Select(m => new MetricDefinition + { + Name = m.Name ?? m.Accessor, + Kind = m.Kind, + Unit = m.Unit, + Description = m.Description, + GroupKeyValues = m.GroupKey + }).ToArray(); + } + + [McpServerTool(Name = "seq_list_metric_dimensions", ReadOnly = true, Title = "List Metric Dimensions")] + [Description("List the dimensions associated with a given metric.")] + [return: Description("Dimension accessor expressions, one per line.")] + public async Task ListDimensionsAsync( + [Description("The maximum number of metric dimensions to return.")] + [Range(1, 1000)] + int limit, + [Description("An ISO 8601 timestamp specifying the lower bound for the search range.")] + DateTimeOffset from, + [Description("The upper bound for the search range.")] + DateTimeOffset to, + [Description("A human-readable metric name, for example `hats-sold` or `http.request.duration`; omit to list dimensions for all metrics.")] + string? metric = null, + CancellationToken cancellationToken = default) + { + var dimensions = await connection.Metrics.ListDimensionsAsync(limit, metric, from.UtcDateTime, to.UtcDateTime, + session.DataToolCallTimeout, cancellationToken: cancellationToken); + + var result = new StringWriter(); + foreach (var dimension in dimensions) + { + await result.WriteLineAsync(dimension.Accessor); + } + + return McpResults.SimpleText(result.ToString()); + } + + [McpServerTool(Name = "seq_list_metric_dimension_values", ReadOnly = true, Title = "List Metric Dimension Values")] + [Description("List the unique values present in a given metric dimension.")] + [return: Description("Dimension values in Seq native syntax, one per line.")] + public async Task ListDimensionValuesAsync( + [Description("The maximum number of values to return.")] + [Range(1, 1000)] + int limit, + [Description("An ISO 8601 timestamp specifying the lower bound for the search range.")] + DateTimeOffset from, + [Description("The upper bound for the search range.")] + DateTimeOffset to, + [Description("The dimension accessor, e.g. `cpu.mode`.")] + string? dimension = null, + CancellationToken cancellationToken = default) + { + var values = await connection.Metrics.ListDimensionValuesAsync(dimension, limit, from.UtcDateTime, to.UtcDateTime, + session.DataToolCallTimeout, cancellationToken: cancellationToken); + + var result = new StringWriter(); + foreach (var value in values) + { + NativeFormatter.WriteValue(result, value); + await result.WriteLineAsync(); + } + + return McpResults.SimpleText(result.ToString()); + } +} diff --git a/src/SeqCli/Mcp/Tools/Query/QueryTools.cs b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs new file mode 100644 index 00000000..11175ac8 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs @@ -0,0 +1,83 @@ +// 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.ComponentModel; +using System.IO; +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.Signals; +using SeqCli.Output; +using SeqCli.Signals; +using Serilog; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Query; + +[McpServerToolType] +class QueryTools(McpSession session, SeqConnection connection) +{ + [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, + [Description("A signal expression restricting the search space. Multiple " + + "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] + string? signal = null, + CancellationToken cancellationToken = default) + { + if (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 McpResults.SimpleText("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); + } + + SignalExpressionPart? parsedSignalExpression = null; + if (!string.IsNullOrWhiteSpace(signal)) + parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); + + QueryResultPart result; + try + { + result = await connection.Data.TryQueryAsync(query, signal: parsedSignalExpression, timeout: session.DataToolCallTimeout, cancellationToken: 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 McpResults.SimpleText($"The query failed. {error}", isError: true); + } + + var output = new StringWriter(); + NativeFormatter.WriteQueryResult(output, result); + return McpResults.SimpleText(output.ToString(), isError: !string.IsNullOrWhiteSpace(result.Error)); + } +} diff --git a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs similarity index 60% rename from src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs rename to src/SeqCli/Mcp/Tools/Search/SearchTools.cs index c8f19517..42343c8a 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchAndQueryToolType.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs @@ -24,9 +24,7 @@ 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.Api.Model.Signals; using Seq.Syntax.Templates; using SeqCli.Mapping; @@ -41,14 +39,23 @@ namespace SeqCli.Mcp.Tools.Search; [McpServerToolType] -class SearchAndQueryToolType(McpSession session, SeqConnection connection) +class SearchTools(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")] + [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; + } + + [McpServerTool(Name = "seq_search_events", 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` " + @@ -71,36 +78,14 @@ public async Task SearchEventsAsync( !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`). " + + return McpResults.SimpleText("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() - } - ], - }; - } + var strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); if (strict.MatchedAsText) { - return SimpleTextResult($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", + return McpResults.SimpleText($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", isError: true); } } @@ -112,7 +97,7 @@ public async Task SearchEventsAsync( var resultsLock = new Lock(); string? error = null; var results = new List(); - var timeout = Task.Delay(TimeSpan.FromSeconds(45), cancellationToken); + var timeout = Task.Delay(session.DataToolCallTimeout, cancellationToken); using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var cancelEnumerateToken = cancelEnumerate.Token; var enumerate = Task.Run(async () => @@ -141,10 +126,9 @@ public async Task SearchEventsAsync( lock (resultsLock) { - error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : ex.ToString(); + error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : "The Seq API call failed."; } } - }, cancellationToken); var completed = await Task.WhenAny(enumerate, timeout) == enumerate; @@ -167,25 +151,15 @@ public async Task SearchEventsAsync( } 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}"; - } + resultSetStatus = takenResults.Length == 0 ? + $"The search failed. {takenError}" : + $"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)."; - } + resultSetStatus = takenResults.Length == 0 ? + "No events matched the search expression." : + $"Showing all {takenResults.Length} matching event(s)."; } else { @@ -225,24 +199,24 @@ public async Task SearchEventsAsync( [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 " + + [Description("Read the full details of an event appearing in `seq_search_events` 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.")] + [Description("The result id from the `seq_search_events` 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)); + return Task.FromResult(McpResults.SimpleText(error, isError: true)); } var resultText = new StringWriter(); NativeFormatter.WriteEvent(resultText, result); - return Task.FromResult(SimpleTextResult(resultText.ToString())); + return Task.FromResult(McpResults.SimpleText(resultText.ToString())); } [McpServerTool(Name = "seq_inspect_result_schema", ReadOnly = true, Title = "Inspect Search Result Schema")] @@ -254,85 +228,4 @@ 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, - [Description("A signal expression restricting the search space. Multiple " + - "signals are intersected with commas, and unioned with tilde, for example, `signal-1,(signal-2~signal-3)`.")] - string? signal = null, - CancellationToken cancellationToken = default) - { - if (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); - } - - SignalExpressionPart? parsedSignalExpression = null; - if (!string.IsNullOrWhiteSpace(signal)) - parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); - - QueryResultPart result; - try - { - result = await connection.Data.TryQueryAsync(query, signal: parsedSignalExpression, cancellationToken: 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 query failed. {error}", isError: true); - } - - var output = new StringWriter(); - NativeFormatter.WriteQueryResult(output, result); - return SimpleTextResult(output.ToString(), isError: !string.IsNullOrWhiteSpace(result.Error)); - } - - [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; - } - - [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals", UseStructuredContent = true)] - [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + - "event streams while dramatically improving response times.")] - public async Task ListSignalsAsync(CancellationToken cancellationToken) - { - return (await connection.Signals.ListAsync(shared: true, partial: true, cancellationToken: cancellationToken)) - .Select(s => new SignalSummary { Id = s.Id, Title = s.Title }) - .ToArray(); - } - - static CallToolResult SimpleTextResult(string resultText, bool isError = false) - { - return new CallToolResult - { - IsError = isError, - Content = - [ - new TextContentBlock - { - Text = resultText - } - ] - }; - } -} \ No newline at end of file +} diff --git a/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs b/src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs similarity index 96% rename from src/SeqCli/Mcp/Tools/Search/SignalSummary.cs rename to src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs index 4f1a048f..0f194e47 100644 --- a/src/SeqCli/Mcp/Tools/Search/SignalSummary.cs +++ b/src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs @@ -14,7 +14,7 @@ using System.ComponentModel; -namespace SeqCli.Mcp.Tools.Search; +namespace SeqCli.Mcp.Tools.Signals; [Description("A signal is a saved, indexed filter over log events and spans.")] class SignalSummary diff --git a/src/SeqCli/Mcp/Tools/Signals/SignalTools.cs b/src/SeqCli/Mcp/Tools/Signals/SignalTools.cs new file mode 100644 index 00000000..f1284aa1 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Signals/SignalTools.cs @@ -0,0 +1,38 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Server; +using Seq.Api; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Signals; + +[McpServerToolType] +class SignalTools(SeqConnection connection) +{ + [McpServerTool(Name = "seq_list_signals", ReadOnly = true, Title = "List Signals", UseStructuredContent = true)] + [Description("List available signals. Use signals when searching and querying to efficiently work with well-known " + + "event streams while dramatically improving response times.")] + public async Task ListSignalsAsync(CancellationToken cancellationToken) + { + return (await connection.Signals.ListAsync(shared: true, partial: true, cancellationToken: cancellationToken)) + .Select(s => new SignalSummary { Id = s.Id, Title = s.Title }) + .ToArray(); + } +} \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs index 080af02f..5ab7e4ae 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs @@ -31,7 +31,7 @@ protected override async Task ExecuteAsync(SeqConnection connection, ILogger log var predicate = $"RunId = '{runId}' and Customer.Tier = 'gold' and @Timestamp >= Now() - 1d"; var searchResult = AssertTextResult(await client.CallToolAsync( - "seq_search", + "seq_search_events", new Dictionary { ["limit"] = 10, ["predicate"] = predicate })); var resultIds = OrderedSearchResultIds(searchResult); Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length); diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs index f38ad8da..91ed9898 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs @@ -12,6 +12,7 @@ namespace SeqCli.EndToEnd.Mcp; // ReSharper disable once UnusedType.Global public class McpSignalUsageTestCase : McpToolTestCase { + // ReSharper disable once NotAccessedPositionalProperty.Local record SignalSummary(string Id, string Title); // Default signals included in every Seq installation. @@ -56,7 +57,7 @@ protected override async Task ExecuteAsync(SeqConnection connection, ILogger log static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal) { var searchResult = AssertTextResult(await client.CallToolAsync( - "seq_search", + "seq_search_events", new Dictionary { ["limit"] = 10, ["predicate"] = predicate, ["signal"] = signal })); return OrderedSearchResultIds(searchResult).Length; } From cae41fedd286ed93deb37e5b0cf3043476c70672 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Sun, 7 Jun 2026 20:01:59 +1000 Subject: [PATCH 82/98] Port metrics CLI tests to cover the metrics MCP tools. Groups argument passing is non-working, and when hard-coded through, uncovers a panic in metric buffer search. Assisted-by: Claude Opus 4.8 --- .../Mcp/McpMetricsBasicsTestCase.cs | 75 +++++++++++++++++++ test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 2 + .../Metrics/MetricsCliBasics.cs | 17 +---- .../Support/DirectIngestion.cs | 22 ++++++ 4 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs create mode 100644 test/SeqCli.EndToEnd/Support/DirectIngestion.cs diff --git a/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs new file mode 100644 index 00000000..6ddd03ad --- /dev/null +++ b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using JetBrains.Annotations; +using ModelContextProtocol.Client; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +#nullable enable + +namespace SeqCli.EndToEnd.Mcp; + +//[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class McpMetricsBasicsTestCase : McpToolTestCase +{ + [UsedImplicitly] + record MetricDefinition(string Name, string Kind, string? Unit, string? Description); + + protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client) + { + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); + + var allMetrics = AssertStructuredResult(await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary { ["limit"] = 100 })); + Assert.Equal(2, allMetrics.Length); + + var groupedByB = AssertStructuredResult(await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary { ["limit"] = 100, ["groups"] = (string[])["b"] })); + Assert.Equal(4, groupedByB.Length); + + var filteredByDescription = AssertStructuredResult(await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary + { + ["limit"] = 100, + ["predicate"] = "\"xyz\" and @Timestamp >= Now() - 1d", + ["groups"] = (string[])["b"] + })); + Assert.Equal(3, filteredByDescription.Length); + + var from = DateTimeOffset.UtcNow.AddDays(-1).ToString("o"); + var to = DateTimeOffset.UtcNow.AddDays(1).ToString("o"); + var allDimensions = TextLines(AssertTextResult(await client.CallToolAsync( + "seq_list_metric_dimensions", + new Dictionary { ["limit"] = 100, ["from"] = from, ["to"] = to }))); + Assert.All(["b", "c", "d"], name => Assert.Contains(name, allDimensions)); + + var dimensionsForE = AssertTextResult(await client.CallToolAsync( + "seq_list_metric_dimensions", + new Dictionary { ["limit"] = 100, ["from"] = from, ["to"] = to, ["metric"] = "e" })); + Assert.Equal("b", dimensionsForE.Trim()); + + var bValues = TextLines(AssertTextResult(await client.CallToolAsync( + "seq_list_metric_dimension_values", + new Dictionary { ["limit"] = 100, ["from"] = from, ["to"] = to, ["dimension"] = "b" }))); + Assert.All(["2", "5"], value => Assert.Contains(value, bValues)); + + var unbounded = await client.CallToolAsync( + "seq_search_metric_definitions", + new Dictionary { ["limit"] = 100, ["predicate"] = "\"xyz\"" }); + Assert.True(unbounded.IsError ?? false); + } + + static string[] TextLines(string text) + { + return text.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } +} diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs index ecd633fc..e713c018 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; +using JetBrains.Annotations; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Seq.Api; @@ -15,6 +16,7 @@ namespace SeqCli.EndToEnd.Mcp; /// Base class for test cases exercising the tools provided by seqcli mcp run. The MCP server /// is spawned over stdio and supplied to the subclass as a connected . /// +[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)] public abstract partial class McpToolTestCase : ICliTestCase { public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) diff --git a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs index 16adf5f5..b134cdcc 100644 --- a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs +++ b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; using System.Threading.Tasks; using Seq.Api; using SeqCli.EndToEnd.Support; @@ -19,9 +17,9 @@ class MetricsCliBasics: ICliTestCase { public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner) { - await IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); - await IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); - await IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 2, '@d': {'a': {'kind': 'Sum', 'description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'c': 3, 'd': 4, '@d': {'a': {'kind': 'Sum','description': 'xyz'}}"); + await DirectIngestion.IngestClef(connection, "'a': 1, 'b': 5, 'e': 6, '@d': {'a': {'kind': 'Sum','description': 'xyz'}, 'e': {'kind': 'Sum', 'description': 'ghi'}}"); Assert.Equal(2, SearchResultLines(runner).Count()); Assert.Equal(4, SearchResultLines(runner, groups: ["b"]).Count()); @@ -62,13 +60,4 @@ static IEnumerable SearchResultLines(CliCommandRunner runner, string? fi } } } - - static async Task IngestClef(SeqConnection connection, string fields) - { - var prefix = $"{{\"@t\":\"{DateTime.UtcNow:o}\","; - const string suffix = "}"; - var content = new StringContent($"{prefix}{fields.Replace("'", "\"")}{suffix}"); - var response = await connection.Client.HttpClient.PostAsync("ingest/clef", content); - Assert.Equal(HttpStatusCode.Created, response.StatusCode); - } } \ No newline at end of file diff --git a/test/SeqCli.EndToEnd/Support/DirectIngestion.cs b/test/SeqCli.EndToEnd/Support/DirectIngestion.cs new file mode 100644 index 00000000..32a32d2e --- /dev/null +++ b/test/SeqCli.EndToEnd/Support/DirectIngestion.cs @@ -0,0 +1,22 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Seq.Api; +using Xunit; + +namespace SeqCli.EndToEnd.Support; + +public static class DirectIngestion +{ + // In questionable taste, but very handy, `fields` carries the comma-separated `'key': value` pairs massaged + // into JSON by replacing `'` with `"`. + public static async Task IngestClef(SeqConnection connection, string fields) + { + var prefix = $"{{\"@t\":\"{DateTime.UtcNow:o}\","; + const string suffix = "}"; + var content = new StringContent($"{prefix}{fields.Replace("'", "\"")}{suffix}"); + var response = await connection.Client.HttpClient.PostAsync("ingest/clef", content); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } +} \ No newline at end of file From 88a70cb26bc8f78ef28a5f50d1c3c18de3513df0 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 08:37:35 +1000 Subject: [PATCH 83/98] Mark the Usage: seqcli [] Type `seqcli help` for available commands command group as non-prerelease --- src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs | 2 +- src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs index e08fb32c..1aca84b1 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs @@ -27,7 +27,7 @@ namespace SeqCli.Cli.Commands.Forwarder; // ReSharper disable once ClassNeverInstantiated.Global -[Command("forwarder", "install", "Install the forwarder as a Windows service", Visibility = FeatureVisibility.Preview, Platforms = SupportedPlatforms.Windows)] +[Command("forwarder", "install", "Install the forwarder as a Windows service", Platforms = SupportedPlatforms.Windows)] [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] class InstallCommand : Command { diff --git a/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs index d6938a4b..c53937ad 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/RestartCommand.cs @@ -22,7 +22,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "restart", "Restart the forwarder Windows service", Visibility = FeatureVisibility.Preview, Platforms = SupportedPlatforms.Windows)] +[Command("forwarder", "restart", "Restart the forwarder Windows service", Platforms = SupportedPlatforms.Windows)] [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] class RestartCommand : Command { diff --git a/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs index ace247d8..f927b3fc 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/RunCommand.cs @@ -44,7 +44,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "run", "Listen on an HTTP endpoint and forward ingested logs to Seq", Visibility = FeatureVisibility.Preview)] +[Command("forwarder", "run", "Listen on an HTTP endpoint and forward ingested logs to Seq")] class RunCommand : Command { readonly StoragePathFeature _storagePath; diff --git a/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs index 1e6b54b4..164526b3 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/StartCommand.cs @@ -20,7 +20,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "start", "Start the forwarder Windows service", Visibility = FeatureVisibility.Preview, Platforms = SupportedPlatforms.Windows)] +[Command("forwarder", "start", "Start the forwarder Windows service", Platforms = SupportedPlatforms.Windows)] [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] class StartCommand : Command { diff --git a/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs index c148a6f3..968e9927 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/StatusCommand.cs @@ -20,7 +20,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "status", "Show the status of the forwarder Windows service", Visibility = FeatureVisibility.Preview, Platforms = SupportedPlatforms.Windows)] +[Command("forwarder", "status", "Show the status of the forwarder Windows service", Platforms = SupportedPlatforms.Windows)] [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] class StatusCommand : Command { diff --git a/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs index c26c2785..c519b482 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/StopCommand.cs @@ -20,7 +20,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "stop", "Stop the forwarder Windows service", Visibility = FeatureVisibility.Preview, Platforms = SupportedPlatforms.Windows)] +[Command("forwarder", "stop", "Stop the forwarder Windows service", Platforms = SupportedPlatforms.Windows)] [SuppressMessage("Interoperability", "CA1416:Validate platform compatibility")] class StopCommand : Command { diff --git a/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs index a62b7df9..ac9ba23b 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/TruncateCommand.cs @@ -20,7 +20,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "truncate", "Empty the forwarder's persistent log buffer", Visibility = FeatureVisibility.Preview)] +[Command("forwarder", "truncate", "Empty the forwarder's persistent log buffer")] class TruncateCommand : Command { readonly StoragePathFeature _storagePath; diff --git a/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs index a72c9e09..4a83d90a 100644 --- a/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs +++ b/src/SeqCli/Cli/Commands/Forwarder/UninstallCommand.cs @@ -20,7 +20,7 @@ namespace SeqCli.Cli.Commands.Forwarder; -[Command("forwarder", "uninstall", "Uninstall the forwarder Windows service", Visibility = FeatureVisibility.Preview, Platforms = SupportedPlatforms.Windows)] +[Command("forwarder", "uninstall", "Uninstall the forwarder Windows service", Platforms = SupportedPlatforms.Windows)] class UninstallCommand : Command { protected override Task Run() From 6e01fc8a55b06fb785afd7f5f6db5e1b88e62e9b Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 10:30:27 +1000 Subject: [PATCH 84/98] Fix string[] parameter binding --- src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 19 ++++++++++++++++++- src/SeqCli/SeqCli.csproj | 19 ++++++------------- .../Properties/launchSettings.json | 4 ++++ 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs index 3dd9fbca..d9d024eb 100644 --- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -14,6 +14,10 @@ using System; using System.Threading.Tasks; +using Autofac; +using Autofac.Builder; +using Autofac.Core; +using Autofac.Core.Resolving.Pipeline; using Autofac.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -61,7 +65,20 @@ protected override async Task Run() try { var builder = Host.CreateApplicationBuilder(); - builder.ConfigureContainer(new AutofacServiceProviderFactory()); + builder.ConfigureContainer(new AutofacServiceProviderFactory(container => + { + // The MCP SDK tries to use the container to resolve any parameter type; Autofac's default collection + // registrations cause array parameters to resolve to empty arrays. We thwart this by short-circuiting + // the search for matching registrations. + var stringArray = new TypedService(typeof(string[])); + container.RegisterServiceMiddleware(PipelinePhase.ResolveRequestStart, (rr, ctx) => + { + if (rr.Service == stringArray) + return; + + ctx(rr); + }); + })); builder.Services.AddSerilog(); builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); builder.Services.AddSingleton(); diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 4344de70..7864a99c 100644 --- a/src/SeqCli/SeqCli.csproj +++ b/src/SeqCli/SeqCli.csproj @@ -19,19 +19,12 @@ true - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - + + + + + + diff --git a/test/SeqCli.EndToEnd/Properties/launchSettings.json b/test/SeqCli.EndToEnd/Properties/launchSettings.json index 0e1d1c7e..a535203b 100644 --- a/test/SeqCli.EndToEnd/Properties/launchSettings.json +++ b/test/SeqCli.EndToEnd/Properties/launchSettings.json @@ -15,6 +15,10 @@ "SeqCli.EndToEnd (datalust/seq:preview)": { "commandName": "Project", "commandLineArgs": "--docker-server --pre" + }, + "SeqCli.EndToEnd (McpMetrics*)": { + "commandName": "Project", + "commandLineArgs": "--docker-server --pre McpMetrics" } } } From 7f95d887094834f99ba20402126205e5bc143d11 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 10:38:14 +1000 Subject: [PATCH 85/98] Invoke Deserialize() as an extension method --- test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs index e713c018..b91697c7 100644 --- a/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs +++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs @@ -50,7 +50,7 @@ protected static T AssertStructuredResult(CallToolResult callToolResult) // Tools returning non-object values have them wrapped in a `result` property by the MCP // SDK, because the protocol requires `structuredContent` to be an object. var result = callToolResult.StructuredContent.Value.GetProperty("result"); - return JsonSerializer.Deserialize(result, JsonSerializerOptions.Web)!; + return result.Deserialize(JsonSerializerOptions.Web)!; } protected static string[] OrderedSearchResultIds(string searchResult) From 46e6cad5bb45d04ddc7ae005a98ba9f5e86ee29c Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 11:00:34 +1000 Subject: [PATCH 86/98] Quick README fix for non-pre Forwarder commands --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b855a58f..60aca3dc 100644 --- a/README.md +++ b/README.md @@ -1868,7 +1868,7 @@ PS > seqcli signal list -i signal-m33302 --json {"Title": "Alarms", "Description": "Automatically created", "Filters": [{"De... ``` -## Store-and-forward ingestion proxy (preview) +## Store-and-forward ingestion proxy The `seqcli forwarder` family of commands provide simple, durable ingestion buffering for occasionally-connected and intermittently-disconnected systems. The forwarder implements the Seq ingestion API, so applications that write @@ -1890,12 +1890,9 @@ destination Seq server. To start a forwarder instance at the terminal, listening on port 5341 and forwarding to `seq.example.com`, run: ```shell -seqcli forwarder run --pre --listen http://127.0.0.1:5341 -s https://seq.example.com +seqcli forwarder run --listen http://127.0.0.1:5341 -s https://seq.example.com ``` -> While the `forwarder` command group is in preview, all `forwarder` commands require the `--pre` switch; you'll -> also need to supply `--pre` when requesting help, e.g. `seqcli help forwarder run --pre`. - You can test your forwarder using the `seqcli log` command: ```shell From 12ffbf723b4ee942b91967a4a47940bea20432d9 Mon Sep 17 00:00:00 2001 From: Nicholas Blumhardt Date: Mon, 8 Jun 2026 18:46:23 +1000 Subject: [PATCH 87/98] Add the app building skill Assisted-by: Claude Opus 4.7 --- .../building-seq-plug-in-apps/SKILL.md | 532 ++++++++++++++++++ 1 file changed, 532 insertions(+) create mode 100644 src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md diff --git a/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md new file mode 100644 index 00000000..4aaadd78 --- /dev/null +++ b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md @@ -0,0 +1,532 @@ +--- +name: building-seq-plug-in-apps +description: Use this when developing plug-in Seq inputs (for ingestion) and outputs (for alert notifications or streamed events). +license: Apache-2.0 +metadata: + author: Datalust and Contributors +--- + +This skill covers building [Seq](https://datalust.co/seq) apps (plugins) using the `Seq.Apps` runtime API. Seq apps are .NET libraries that extend Seq with custom event processing (output apps) or event generation (input apps). + +## Project structure + +A Seq app follows this layout: + +``` +src/ + Seq.App.{Name}/ + Seq.App.{Name}.csproj # Main library + {Name}App.cs # SeqApp subclass (entry point) + ... # Supporting types + Resources/ # Embedded resources (default templates, etc.) + Seq.App.{Name}.SmokeTest/ # Optional console app for manual testing + Program.cs +test/ + Seq.App.{Name}.Tests/ + {Name}AppTests.cs # App-level integration tests + ...Tests.cs # Unit tests for components + Support/ + Test{Gateway}.cs # Test doubles + Some.cs # LogEvent factory helpers +``` + +Input apps use `Seq.Input.{Name}` naming. + +### `.csproj` conventions + +```xml + + + net8.0 + latest + enable + true + + + + + + +``` + +Embedded resources for default templates use `LogicalName` for clean resource stream access: + +```xml + + + +``` + +Test projects target the latest stable .NET release, and use xUnit. + +The runtime pins its own copies of `Seq.Apps`, `Serilog`, and `Seq.Syntax` — the app's copies of these assemblies are ignored at load time. Other dependencies are loaded from the app's package. + +### Namespace disambiguation + +Projects named `Seq.App.{Name}` have a root namespace that collides with `Seq.Apps`. Use `global::` where `Seq.Apps.App` or `Seq.Apps.Host` is referenced as a type within a `Seq.App.*` namespace: + +```csharp +public static LogEventPropertyValue? MyAppHost(global::Seq.Apps.Host host) { ... } +public static LogEventPropertyValue? MyAppInstance(global::Seq.Apps.App app) { ... } +``` + +Use `InternalsVisibleTo` in the main project to expose `internal` constructors and types to the test project. + +## App class structure + +### Output apps (event subscribers) + +Output apps derive from `SeqApp` and implement `ISubscribeToAsync`: + +```csharp +using Seq.Apps; +using Serilog.Events; + +[SeqApp("App Name", + Description = "A short sentence describing what the app does.")] +public class MyApp : SeqApp, ISubscribeToAsync, IDisposable +{ + readonly IMyGateway _gateway; + MyMessageFactory? _messageFactory; + + // Public parameterless constructor for production use + public MyApp() : this(new MyHttpGateway()) { } + + // Internal constructor for testing (dependency injection) + internal MyApp(IMyGateway gateway) { _gateway = gateway; } + + // Settings (see "App settings" section below) + + protected override void OnAttached() + { + // Validate required settings, resolve defaults, create factories + } + + public async Task OnAsync(Event evt) + { + // Process the event: render message, send via gateway + } + + public void Dispose() + { + (_gateway as IDisposable)?.Dispose(); + } +} +``` + +The `SeqApp` attribute's `Name` parameter is the display name in Seq's UI. Keep it short (1-3 words). `Description` is a single sentence shown below the name. + +### Output apps using raw JSON (`ISubscribeToJsonAsync`) + +Apps that don't need Serilog `LogEvent` deserialization can implement `ISubscribeToJsonAsync` instead. This receives each event as a raw [CLEF](https://clef-json.org) JSON string, skipping `LogEvent`/`MessageTemplate`/`LogEventProperty` construction entirely: + +```csharp +[SeqApp("App Name", + Description = "A short sentence describing what the app does.")] +public class MyApp : SeqApp, ISubscribeToJsonAsync, IAsyncDisposable +{ + public async Task OnAsync(string json) + { + // Parse and process the raw CLEF JSON + } + + public async ValueTask DisposeAsync() { } +} +``` + +Prefer `ISubscribeToJsonAsync` for apps that forward or transform events without rendering human-readable output (exporters, relays, bridges), especially at high volume. It avoids the Serilog dependency entirely. The trade-off is that the `Event` wrapper (`Id`, `EventType`, `Timestamp`) and `Seq.Syntax` template rendering are not available; the app must extract CLEF fields (`@t`, `@l`, `@mt`, `@x`, etc.) from the JSON directly. + +Stick with `ISubscribeToAsync` when the app renders human-readable output using `Seq.Syntax` templates (emails, chat messages, tickets) or inspects structured property values using the Serilog type system (`ScalarValue`, `SequenceValue`, `StructureValue`). + +### Input apps (event publishers) + +Input apps implement `IPublishJson`: + +```csharp +[SeqApp("My Input", + Description = "Periodically does X and publishes metrics to Seq.")] +public class MyInput : SeqApp, IPublishJson, IDisposable +{ + public void Start(TextWriter inputWriter) + { + // Begin producing events; write CLEF JSON lines to inputWriter + // Return immediately; use background tasks for ongoing work + } + + public void Stop() + { + // Block until publishing has stopped + } +} +``` + +The app must synchronize writes to `inputWriter` so that events are not interleaved (e.g., use a `lock` when multiple background threads produce events). + +### Lifecycle + +1. Seq instantiates the app using the public parameterless constructor +2. The runtime sets `[SeqAppSetting]` properties via reflection, matched by **property name** +3. `Attach()` is called, making `App`, `Host`, and `Log` available +4. `OnAttached()` runs — validate settings and initialize here +5. For subscribers: `OnAsync()` is called for each matching event +6. For publishers: `Start()` is called, then `Stop()` on shutdown +7. `Dispose()` if implemented + +Each app instance runs as a separate `seqcli` process. Initialization, event dispatch, and disposal are single-threaded. Events are dispatched one at a time — if `OnAsync()` is slow, backpressure is applied naturally. Apps may create their own background threads for ingestion. + +## App settings + +Settings are public properties on the app class decorated with `[SeqAppSetting]`. They appear in Seq's UI for the user to configure. The runtime injects values by matching on the **property name** (not `DisplayName`), using `Enum.Parse` for enum types and `Convert.ChangeType` for everything else. + +### `SeqAppSetting` attribute properties + +| Property | Type | Purpose | +|---|---|---| +| `DisplayName` | `string` | User-facing label in the UI. If omitted, the property name is used. | +| `HelpText` | `string` | Descriptive text shown to the user. Should explain the setting's purpose and give examples. | +| `IsOptional` | `bool` | If `true`, the user can leave the field blank. Non-optional settings that are missing cause a startup error before `Attach()` is called. | +| `InputType` | `SettingInputType` | Controls the UI input widget: `Text`, `LongText`, `Checkbox`, `Integer`, `Decimal`, `Password`. If `Unspecified`, the runtime chooses based on the property type. | +| `Syntax` | `string` | Syntax highlighting in the UI. `"template"` for fields accepting Seq template expressions (in `{braces}`), `"code"` for structured values (JSON, headers). Omit for plain values. | +| `IsInvocationParameter` | `bool` | Marks settings that can be overridden per-invocation (e.g., destination address, channel). | + +### Quality guidelines for settings + +**Display names** should be concise, title-case-ish, and match how users think about the concept: + +| Good | Avoid | +|---|---| +| Bot token | BotToken | +| Chat ID | Telegram Chat Identifier | +| Silent notification | DisableNotification | +| Body is plain text | BodyIsPlainText | +| Connection security | ProtocolSecurity | + +**Help text** should: +- Start with a clear description of the setting's purpose +- Include concrete examples where helpful (e.g., format strings, URL patterns, expected values) +- Mention defaults explicitly (e.g., "The default is `Etc/UTC`.", "The default is `POST`.") +- Use backticks for code/values in help text +- Note when template syntax is supported: "Template syntax is supported." +- For password/token fields, explain how to obtain the value + +```csharp +[SeqAppSetting( + DisplayName = "Time zone", + IsOptional = true, + HelpText = "The IANA time zone name used when formatting dates and times " + + "(e.g. Australia/Brisbane). The default is Etc/UTC.")] +public string? TimeZoneName { get; set; } + +[SeqAppSetting( + DisplayName = "Bot token", + InputType = SettingInputType.Password, + HelpText = "The bot's API token, in the format 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11. " + + "You can create a bot and obtain a token via @BotFather on Telegram.")] +public string? BotToken { get; set; } +``` + +### Enum settings + +When a setting has a fixed set of valid values, use an `enum` type. The runtime presents enum settings as a dropdown and handles validation automatically. Apply `[Description]` to enum members to control the dropdown text when the member name doesn't read naturally: + +```csharp +using System.ComponentModel; + +public enum OtlpExportProtocol +{ + [Description("HTTP/Protobuf")] + HttpProtobuf, + [Description("gRPC")] + Grpc +} + +[SeqAppSetting( + DisplayName = "Protocol", + HelpText = "The OTLP export protocol. The default is `HTTP/Protobuf`.", + IsOptional = true)] +public OtlpExportProtocol Protocol { get; set; } = OtlpExportProtocol.HttpProtobuf; +``` + +### Setting validation + +Validate required settings in `OnAttached()` and throw `ArgumentException` with a message that names the setting using its display name in backticks: + +```csharp +protected override void OnAttached() +{ + var botToken = NormalizeOption(BotToken) + ?? throw new ArgumentException("A `Bot token` must be supplied."); + var chatId = NormalizeOption(ChatId) + ?? throw new ArgumentException("A `Chat ID` must be supplied."); + // ... +} + +// Treat empty strings as null for optional settings +static string? NormalizeOption(string? s) => s == "" ? null : s; +``` + +## Gateway pattern (testability) + +External I/O (HTTP APIs, SMTP, cloud services) is abstracted behind an interface so tests can run without network access. + +```csharp +// Interface +interface IMyGateway +{ + Task SendAsync(string param1, string param2, CancellationToken cancel); +} + +// Production implementation +class MyHttpGateway : IMyGateway, IDisposable +{ + readonly HttpClient _httpClient = new(); + + public async Task SendAsync(...) + { + // HTTP calls, JSON serialization, response parsing + } + + public void Dispose() => _httpClient.Dispose(); +} + +// Test double +class TestMyGateway : IMyGateway +{ + public List<(string Param1, string Param2)> Sent { get; } = []; + public MyResponse NextResponse { get; set; } = new() { Ok = true }; + + public Task SendAsync(string param1, string param2, CancellationToken cancel) + { + Sent.Add((param1, param2)); + return Task.FromResult(NextResponse); + } +} +``` + +The app class has dual constructors: + +```csharp +public MyApp() : this(new MyHttpGateway()) { } // Production +internal MyApp(IMyGateway gateway) { _gateway = gateway; } // Testing +``` + +Use a single long-lived `HttpClient` per gateway. Use `System.Text.Json` for serialization — no third-party HTTP or serialization libraries. + +## Template support (Seq.Syntax) + +Apps that support user-customizable output should use the Seq template language via `Seq.Syntax`. This is the same expression language used in Seq's UI. + +### Template compilation + +Templates are compiled into `ExpressionTemplate` instances in `OnAttached()` or a factory constructor: + +```csharp +using Seq.Syntax.Templates; +using Seq.Syntax.Templates.Encoding; + +var template = new ExpressionTemplate( + templateText, + nameResolver: new OrderedNameResolver(new NameResolver[] + { + new StaticMemberNameResolver(typeof(MyBuiltInFunctions)), + myAppNameResolver + }), + encoder: isPlainText ? null : new MyHtmlEncoder()); +``` + +### Template rendering + +Render a template against a `LogEvent`: + +```csharp +static string Format(ExpressionTemplate template, LogEvent evt) +{ + var writer = new StringWriter(); + template.Format(evt, writer); + return writer.ToString(); +} +``` + +### Custom encoding + +When output is HTML (or another format requiring escaping), implement `TemplateOutputEncoder`. The encoder is applied **only to interpolated values**, not to template literal text: + +```csharp +class TemplateOutputTelegramHtmlEncoder : TemplateOutputEncoder +{ + public override string Encode(string value) + { + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">"); + } +} +``` + +### Built-in functions and name resolution + +Apps can expose custom functions and properties within templates through a `NameResolver`: + +```csharp +public static class MyAppBuiltInFunctions +{ + // Available as UriEncode(value) in templates + public static LogEventPropertyValue? UriEncode(LogEventPropertyValue? value) { ... } + + // Bound to @Host in templates via the name resolver + public static LogEventPropertyValue? MyAppHost(Host host) { ... } + + // Bound to @App in templates via the name resolver + public static LogEventPropertyValue? MyAppInstance(App app) { ... } +} +``` + +The name resolver maps `@Host` and `@App` to the built-in functions and binds the actual `Host` and `App` instances as function parameters. See `TelegramAppNameResolver` or `MailAppNameResolver` in the reference apps for the implementation pattern. + +### Default templates + +Provide sensible default templates as embedded resources. Templates should: +- Handle both regular events and alert notifications (`@EventType = 0xA1E77001`) +- Include level indicators appropriate to the output format +- Show timestamp, message, properties, and exceptions +- Link back to the event in Seq using `@Host.BaseUri` + +For HTML templates, use only tags supported by the target (e.g., Telegram supports only ``, ``, ``, `
`, ``).
+
+## Error handling
+
+All exceptions thrown from `OnAsync()` are caught per-event by the runtime, logged to the app's diagnostic stream, and recorded against the event. The app continues processing the next event regardless.
+
+| Scenario | Pattern |
+|---|---|
+| Missing required settings | `ArgumentException` from `OnAttached()` — Seq reports as a configuration error |
+| Invalid template syntax | `ArgumentException` from `ExpressionTemplate` constructor — fails at attachment time |
+| Transient API errors (rate limits) | Log via `Log.Warning()`, do **not** throw — avoids per-event failure noise |
+| Permanent API errors (auth, bad request) | Throw `SeqAppException` with the error details — Seq records against the event |
+| Network failures | Let `HttpRequestException` propagate — Seq records against the event |
+
+```csharp
+public async Task OnAsync(Event evt)
+{
+    var response = await _gateway.SendAsync(...);
+    if (!response.Ok)
+    {
+        if (response.ErrorCode == 429) // Rate limited
+        {
+            Log.Warning("Rate limit exceeded; retry after {RetryAfter}s", response.RetryAfter);
+            return; // Don't throw
+        }
+
+        throw new SeqAppException($"API error {response.ErrorCode}: {response.Description}");
+    }
+}
+```
+
+## Testing
+
+### Test infrastructure
+
+**`TestAppHost`** (from `Seq.Apps.Testing`): Provides a minimal `IAppHost` with default `App` (id `"app-1"`, base URI `https://seq.example.com`), `Host`, and `Logger` instances. Used to attach the app in tests.
+
+### Test pattern
+
+Setting properties are set directly on the app instance before calling `Attach()`, since the runtime injects them by reflection in production but tests bypass that:
+
+```csharp
+[Fact]
+public async Task EventsAreSentWithCorrectParameters()
+{
+    var gateway = new TestMyGateway();
+    var app = new MyApp(gateway);
+    app.BotToken = "test-token";
+    app.ChatId = "12345";
+    app.Attach(new TestAppHost());
+
+    await app.OnAsync(Some.InformationEvent());
+
+    Assert.Single(gateway.Sent);
+    Assert.Equal("12345", gateway.Sent[0].ChatId);
+}
+```
+
+For `ISubscribeToJsonAsync` apps, pass a CLEF JSON string directly:
+
+```csharp
+await app.OnAsync("""{"@t":"2024-01-15T10:30:00Z","@mt":"Test event"}""");
+```
+
+A `Some` helper class is typically used to create `Event` test fixtures for `ISubscribeToAsync` apps.
+
+### Test categories
+
+**App-level tests** exercise the full path from `OnAsync()` through the gateway using test doubles:
+
+- Happy path: event flows end-to-end and the gateway receives the expected payload
+- Different log levels produce appropriate output (level indicators, severity mapping)
+- Alert events (`EventType = 0xA1E77001`) are handled distinctly from regular events
+- Events with exceptions (`@x`) include the exception in output
+- Events with missing optional properties degrade gracefully
+- Structured property values (sequences, structures, dictionaries) render correctly
+
+**Configuration tests** cover `OnAttached()`:
+
+- Each required setting, when missing or empty, throws `ArgumentException` naming the setting
+- Invalid combinations and out-of-range values are rejected
+- Optional settings fall back to documented defaults
+
+**Template/rendering tests** (for apps using `Seq.Syntax`):
+
+- Default templates render sensibly for both regular events and alerts
+- HTML/encoded output: interpolated values are encoded, literal template text is not
+- Special characters in property values don't break the output format
+- Built-in functions (`@Host`, `@App`, custom functions) resolve correctly
+- Output truncation at size limits preserves valid output
+
+**Error handling tests**:
+
+- Gateway errors throw `SeqAppException` with a diagnostic message
+- Transient errors (rate limits) log but do not throw
+- Error context is sufficient for diagnosis (status codes, response excerpts)
+
+**For `ISubscribeToJsonAsync` apps**, test with realistic CLEF inputs covering minimal events (`@t` + `@mt`), events with all optional fields (`@l`, `@x`, `@i`, `@tr`, `@sp`, `@st`), and properties containing special characters, nested structures, and arrays.
+
+### Smoke test project
+
+An optional console app for manual end-to-end verification against real services. Reads settings from `SEQ_APP_SETTING_{PROPERTYNAME}` environment variables (uppercase, no underscores in the property name part).
+
+Take care that the smoke test project doesn't exit or assume completion before asynchronous background processes complete, e.g. flushing buffered output to disk or remote APIs.
+
+## Runtime API reference
+
+### Key types from `Seq.Apps` (in `seq-apps-runtime`)
+
+| Type | Purpose |
+|---|---|
+| `SeqApp` | Abstract base class. Provides `App`, `Host`, `Log` after attachment. |
+| `SeqAppAttribute` | Marks the entry point class. `Name` (short UI label), `Description` (sentence), `AllowReprocessing` (default `false`; if `true`, the app will receive events that it produced itself). |
+| `SeqAppSettingAttribute` | Marks configurable properties. See "App settings" section. |
+| `ISubscribeToAsync` | For output apps processing Serilog `LogEvent` objects. |
+| `ISubscribeToJsonAsync` | For output apps processing raw CLEF JSON strings. |
+| `IPublishJson` | For input apps that generate events. `Start(TextWriter)` / `Stop()`. |
+| `Event` | Wrapper with `Id`, `EventType`, `Timestamp`, `Data`. Alert events have `EventType = 0xA1E77001`. |
+| `App` | `Id`, `Title`, `Settings` (`IReadOnlyDictionary`), `StoragePath`. |
+| `Host` | `BaseUri`, `InstanceName` (nullable). |
+| `SeqAppException` | Throw for app-specific errors; Seq records against the event. |
+| `SettingInputType` | Enum: `Unspecified`, `Text`, `LongText`, `Checkbox`, `Integer`, `Decimal`, `Password`. |
+
+### Important constants
+
+- Alert event type: `0xA1E77001`
+- Default time zone: `Etc/UTC`
+- Default date/time format: `o` (ISO-8601 round-trip)
+
+## References
+
+- [CLEF specification](https://clef-json.org) — the Compact Log Event Format (`@t`, `@mt`, `@m`, `@l`, `@x`, `@i`, `@r`)
+- [Posting raw events](https://docs.datalust.co/docs/posting-raw-events) — CLEF reference including Seq trace extensions (`@tr`, `@sp`, `@ps`, `@st`, `@sc`, `@ra`, `@sk`)
+- [Template syntax](https://docs.datalust.co/docs/template-syntax) — documentation for the Seq template language used in app settings
+- [seq-apps-runtime](https://github.com/datalust/seq-apps-runtime) — source code for the `Seq.Apps` API (`SeqApp`, `ISubscribeToAsync`, `ISubscribeToJsonAsync`, etc.)
+- [seqcli](https://github.com/datalust/seqcli) — source code for the `seqcli app run` command that Seq uses to host apps at runtime
+- [seq-app-mail](https://github.com/datalust/seq-app-mail) — canonical output app example (email); also the home of `Seq.Syntax` source code
+- [seq-input-healthcheck](https://github.com/datalust/seq-input-healthcheck) — canonical input app example (HTTP health checks)

From 2b76a4ccb2f6c1529c17e9a4252828df08dbe594 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Tue, 9 Jun 2026 11:44:21 +1000
Subject: [PATCH 88/98] Drop out Autofac from `mcp run` - yesterday's hack
 isn't holding up, for some reason

---
 src/SeqCli/Cli/Commands/Mcp/RunCommand.cs | 19 -------------------
 1 file changed, 19 deletions(-)

diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs
index d9d024eb..3d857d51 100644
--- a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs
+++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs
@@ -14,11 +14,6 @@
 
 using System;
 using System.Threading.Tasks;
-using Autofac;
-using Autofac.Builder;
-using Autofac.Core;
-using Autofac.Core.Resolving.Pipeline;
-using Autofac.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using SeqCli.Api;
@@ -65,20 +60,6 @@ protected override async Task Run()
         try
         {
             var builder = Host.CreateApplicationBuilder();
-            builder.ConfigureContainer(new AutofacServiceProviderFactory(container =>
-            {
-                // The MCP SDK tries to use the container to resolve any parameter type; Autofac's default collection
-                // registrations cause array parameters to resolve to empty arrays. We thwart this by short-circuiting
-                // the search for matching registrations.
-                var stringArray = new TypedService(typeof(string[]));
-                container.RegisterServiceMiddleware(PipelinePhase.ResolveRequestStart, (rr, ctx) =>
-                {
-                    if (rr.Service == stringArray)
-                        return;
-
-                    ctx(rr);
-                });
-            }));
             builder.Services.AddSerilog();
             builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config));
             builder.Services.AddSingleton();

From 02dc5db01d5d9bbb912588dcd1babd483876d3c8 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Tue, 9 Jun 2026 15:43:37 +1000
Subject: [PATCH 89/98] PR feedback, basic metrics skill, fixes

---
 ...onCommand.cs => DimensionValuesCommand.cs} |   8 +-
 src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs  |   4 +-
 src/SeqCli/Mcp/Tools/Query/QueryTools.cs      |   7 +
 src/SeqCli/Output/NativeFormatter.cs          |   1 -
 src/SeqCli/Output/OutputFormat.cs             |  29 ++--
 .../Resources/seq-search-and-query/SKILL.md   |  35 ++---
 .../working-with-seq-metrics/SKILL.md         | 140 ++++++++++++++++++
 .../Mcp/McpMetricsBasicsTestCase.cs           |   2 +-
 .../Metrics/MetricsCliBasics.cs               |   2 +-
 test/SeqCli.Tests/SeqCli.Tests.csproj         |   2 +
 .../Skills/SkillComplexityTests.cs            |  39 +++++
 11 files changed, 224 insertions(+), 45 deletions(-)
 rename src/SeqCli/Cli/Commands/Metrics/{DimensionCommand.cs => DimensionValuesCommand.cs} (92%)
 create mode 100644 src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
 create mode 100644 test/SeqCli.Tests/Skills/SkillComplexityTests.cs

diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs
similarity index 92%
rename from src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs
rename to src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs
index f3296992..f4da9047 100644
--- a/src/SeqCli/Cli/Commands/Metrics/DimensionCommand.cs
+++ b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs
@@ -22,9 +22,9 @@
 
 namespace SeqCli.Cli.Commands.Metrics;
 
-[Command("metrics", "dimension", "List distinct values for a metric dimension",
-    Example = "seqcli metrics dimension --accessor @Resource.service.name")]
-class DimensionCommand : Command
+[Command("metrics", "dimensionvalues", "List distinct values for a metric dimension",
+    Example = "seqcli metrics dimensionvalues --accessor @Resource.service.name")]
+class DimensionValuesCommand : Command
 {
     readonly ConnectionFeature _connection;
     readonly OutputFormatFeature _output;
@@ -34,7 +34,7 @@ class DimensionCommand : Command
     int _count = 30;
     bool _trace;
 
-    public DimensionCommand()
+    public DimensionValuesCommand()
     {
         Options.Add(
             "d=|accessor=",
diff --git a/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs b/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs
index 160978df..4eac5be2 100644
--- a/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs
+++ b/src/SeqCli/Mcp/Tools/Metrics/MetricsTools.cs
@@ -37,7 +37,7 @@ class MetricsTools(McpSession session, SeqConnection connection)
     [return: Description("Matching metric definitions.")]
     public async Task SearchMetricsAsync(
         [Description("The maximum number of metric definitions to return.")]
-        [Range(1, 1000)]
+        [Range(1, 512)]
         int limit,
         [Description("A Seq search expression evaluated over metric names (`Keys(@Definitions)[?]`), descriptions " +
                      "(`@Definitions[?].description`), resource attributes, scope attributes, and raw samples.")]
@@ -55,7 +55,7 @@ public async Task SearchMetricsAsync(
                     "The predicate doesn't adequately constrain the search range. " +
                     "To avoid consuming excessive resources, add a time bound such as `@Timestamp >= now() - 4h`.");
             }
-
+            
             var strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken);
             if (strict.MatchedAsText)
             {
diff --git a/src/SeqCli/Mcp/Tools/Query/QueryTools.cs b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs
index 11175ac8..444de98e 100644
--- a/src/SeqCli/Mcp/Tools/Query/QueryTools.cs
+++ b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs
@@ -56,6 +56,13 @@ public async Task QueryAsync(
                                     "To avoid consuming excessive resources, add a time bound such as `where @Timestamp >= now() - 1d`.", isError: true);
         }
 
+        if (query.Contains("series", StringComparison.OrdinalIgnoreCase) &&
+            query.Contains("@Definitions", StringComparison.OrdinalIgnoreCase))
+        {
+            return McpResults.SimpleText("Queries over the `@Definitions` property are not currently permitted. Use dedicated metrics-oriented " +
+                                         "tools or CLI commands to search for metric definitions.", isError: true);
+        }
+
         SignalExpressionPart? parsedSignalExpression = null;
         if (!string.IsNullOrWhiteSpace(signal))
             parsedSignalExpression = SignalExpressionParser.ParseExpression(signal);
diff --git a/src/SeqCli/Output/NativeFormatter.cs b/src/SeqCli/Output/NativeFormatter.cs
index 782a2433..79017ce5 100644
--- a/src/SeqCli/Output/NativeFormatter.cs
+++ b/src/SeqCli/Output/NativeFormatter.cs
@@ -255,7 +255,6 @@ public static void WriteQueryResult(TextWriter output, QueryResultPart result)
                         output.Write(' ');
                     output.Write(heading);
                 }
-                output.WriteLine();
             }
             else
             {
diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs
index dbad8667..affa48ef 100644
--- a/src/SeqCli/Output/OutputFormat.cs
+++ b/src/SeqCli/Output/OutputFormat.cs
@@ -43,6 +43,15 @@ sealed class OutputFormat
     readonly bool _forceColor;
     readonly Logger _formatter;
 
+    readonly JsonSerializer _serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings
+    {
+        DateParseHandling = DateParseHandling.None,
+        Converters =
+        {
+            new StringEnumConverter()
+        }
+    });
+
     public const string DefaultOutputTemplate =
         "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}";
 
@@ -109,12 +118,7 @@ public void WriteEntity(Entity entity)
 
         var jo = JObject.FromObject(
             entity,
-            JsonSerializer.CreateDefault(new JsonSerializerSettings {
-                DateParseHandling = DateParseHandling.None,
-                Converters = {
-                    new StringEnumConverter()
-                }
-            }));
+            _serializer);
             
         if (Json)
         {
@@ -147,18 +151,9 @@ public void WriteObject(object value)
             
         if (Json)
         {
-            var settings = JsonSerializer.CreateDefault(new JsonSerializerSettings
-            {
-                DateParseHandling = DateParseHandling.None,
-                Converters =
-                {
-                    new StringEnumConverter()
-                }
-            });
-            
             var jo = value is ICollection and not (IDictionary or JToken) ?
-                (JToken)JArray.FromObject(value, settings) :
-                JObject.FromObject(value, settings);
+                (JToken)JArray.FromObject(value, _serializer) :
+                JObject.FromObject(value, _serializer);
 
             // Using the same method of JSON colorization as above
 
diff --git a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md
index 3619c62a..fc7604cf 100644
--- a/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md
+++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md
@@ -20,16 +20,15 @@ session MUST begin with the following steps:
 
 1. Check for relevant signals. Many event filtering problems have already been solved and the resulting filters saved as efficiently indexed signals.
 2. Retrieve a sample of relevant events. Use signals identified in (1) where appropriate, event search and query supports them.
-3. Confirm the schema of the search results (important!).
+3. Proactively confirm the schema of the search results using appropriate tools.
 4. Inspect a subset of relevant events in full.
 5. Determine how the correctness of any conclusions can be verified using real diagnostic data.
 
-DO NOT skip steps just because early results suggest a quicker path to a solution: this is often the path to being
-confidently wrong!
+DO NOT skip steps just because early results suggest a quicker path to a solution: this is the path to being confidently wrong!
 
 ## Event Data Model
 
-All events stored in Seq use the same data model. Spans are only distinguished from log events by the presence of the
+All events stored in Seq use the same data model. Spans are distinguished from log events by the presence of the
 `@Start` property. The following built-in properties are supported.
 
 | Built in property name | Type | Description |
@@ -133,7 +132,7 @@ These built-in functions and operators work with individual values. See Aggregat
 
 ## Aggregate Functions
 
-`select` queries (see grammar below) have access to the following aggregate functions.
+`select` queries have access to the following aggregate functions.
 
 | Aggregate function signature | Description | Example |
 |---|---|---|
@@ -149,21 +148,10 @@ These built-in functions and operators work with individual values. See Aggregat
 | `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)` |
+| `phist(expr: number, count: number, p: number): number` | Bucketed (histogram) percentile. | |
 | `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)` |
 
-## Schema
-
-The `stream` source contains log events and spans. The `series` source contains
-metric samples (not discussed in this skill).
-
-Seq servers are compatible with a vast array of data sources. They may use a mix of OpenTelemetry and
-framework/ecosystem-specific property names, and may do so inconsistently.
-
-When available, **always use the MCP schema tool** to inspect the actual properties appearing on search results. In
-particular, don't skip schema checks early in investigations just because you've seen a few events. Events are
-inconsistent! Use the schema tool at least once just to be safe.
-
 ## Base Grammar
 
 ```ebnf
@@ -291,7 +279,7 @@ Keywords are case-insensitive.
 | `@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. |
+| `@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, using case-insensitive comparisons. |
 | `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. |
@@ -299,7 +287,7 @@ Keywords are case-insensitive.
 | `@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
+## Query Example
 
 Grouped query with ordering:
 
@@ -351,6 +339,15 @@ order by p95_ms desc                           -- order by a selected aggregate'
 limit 100
 ```
 
+## User-Defined Property 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 available, **always use the MCP schema tool** to inspect the actual properties appearing on search results. In
+particular, don't skip schema checks early in investigations just because you've seen a few events. Events are
+inconsistent! Use the schema tool at least once just to be safe.
+
 ## Gotchas
 
  - Group keys are automatically included in result rowsets and **must not** be explicitly included in the `select` list.
diff --git a/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md b/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
new file mode 100644
index 00000000..e9516e8b
--- /dev/null
+++ b/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
@@ -0,0 +1,140 @@
+---
+name: seq-search-and-query
+description: Query metrics stored in Seq. Use this when monitoring data or diagnostic metric information is required.
+license: Apache-2.0
+metadata:
+ author: Datalust and Contributors
+---
+
+Seq stores OpenTelemetry metric samples in the `series` data source, which is a columnar data store.
+
+Metrics are accessed **strictly** using the following workflow:
+
+ 1. Search for metric definitions
+ 2. Note the _kind_ of any relevant metrics
+ 3. List the dimensions that apply to the metric
+ 4. Optionally, list the values present in dimensions of interest
+ 5. Run aggregate queries on metric samples using metric names, kinds, and dimensions discovered in the previous steps
+
+**NEVER** query the `series` data source for metric definitions directly. **ALWAYS** use the dedicated definition search tools instead.
+
+**NEVER** query the `series` data source directly for available dimensions. **ALWAYS** use the dedicated dimension listing tools instead.
+
+**NEVER** list dimension values using the `distinct` aggregation. **ALWAYS** use the dedicated dimension value listing tools instead.
+
+**NEVER** evaluate projection queries against the `series` data source. This risks impacting the performance of Seq for other
+users and workloads.
+
+## Searching for Metric Definitions
+
+Metric samples carry an `@Definitions` object where metric names are keys into the object. Under each metric name key,
+a `description` property is available.
+
+Search for metrics with `cpu` in their **name** or **description** (case-insensitive):
+
+```
+Keys(@Definitions)[?] like '%cpu%' ci or @Definitions[?].description like '%cpu%' ci
+```
+
+Search for metrics reported by a specific service:
+
+```
+@Resource.service.name = 'accounting'
+```
+
+If metrics searches report syntax errors, you MUST load the `seq-search-and-query` skill.
+
+## Listing Dimensions
+
+Determine the dimensions that apply to a metric using the dimensions MCP tool or the `seqcli metrics dimensions` CLI
+command.
+
+## Listing Unique Dimension Values
+
+DO NOT use `distinct` to query for the values of a metric dimension like `@Resource.service.name`. Always use the
+dimension values MCP tool or `seqcli metrics dimensionvalues` CLI command.
+
+## Querying Metric Samples
+
+Metrics returned from definition searches are tagged with a kind:
+
+* `Sum` - counter with delta aggregation temporality
+* `Gauge` - gauge or counter with cumulative aggregation temporality
+* `Fixed` - histogram with fixed buckets
+* `Exponential` - histogram with exponential buckets
+
+Correctly querying metric sample data from `series` relies on knowing the metric kind.
+
+### `Sum` Query Example
+
+```
+select sum(app_product_review_counter) as reviews_submitted
+from series
+where Has(app_product_review_counter)
+  and @Timestamp >= now() - 15m
+group by time(1m)
+```
+
+### `Gauge` Query Examples
+
+```
+select max(dotnet.gc.last_collection.heap.size) as heap_size_bytes
+from series 
+where (Has(dotnet.gc.last_collection.heap.size))
+  and @Resource.service.name = 'accounting'
+  and @Timestamp >= now() - 15m
+group by time(1m), @Resource.service.instance.id
+having heap_size_bytes > 5000000
+limit 100
+```
+
+### `Fixed` Query Examples
+
+HTTP request timing percentiles:
+
+```
+select
+  phist(coalesce((bucket.from + bucket.to) / 2, bucket.from, bucket.to), bucket.count, 95) as p95,
+  phist(coalesce((bucket.from + bucket.to) / 2, bucket.from, bucket.to), bucket.count, 99) as p99
+from series lateral unnest(http.server.request.duration.buckets) as bucket 
+where Has(http.server.request.duration.buckets)
+  and @Resource.service.name = 'cart'
+  and @Scope.name = 'Microsoft.AspNetCore.Hosting'
+  and @Timestamp >= now() - 15m
+group by time(1m)
+limit 100
+```
+
+HTTP request througput (counts):
+
+```
+select sum(http.server.request.duration.count) as request_throughput
+from series
+where Has(http.server.request.duration.buckets)
+  and @Resource.service.name = 'cart'
+  and @Scope.name = 'Microsoft.AspNetCore.Hosting'
+  and @Timestamp >= now() - 15m
+group by time(1m)
+limit 100
+```
+
+Maximium HTTP request body size:
+
+```
+select max(http.server.request.body.size.max) as max_body_size
+from series 
+where Has(http.server.request.body.size.buckets)
+  and @Resource.service.name = 'shipping'
+  and @Scope.name = 'opentelemetry-instrumentation-actix-web'
+  and @Timestamp >= now() - 15m
+group by time(1m)
+limit 100
+```
+
+## Gotchas
+
+- If metrics that should be _counters_ show up as _gauges_, aggregation temporality is likely to be misconfigured: danger! This can produce wildly inaccurate results if not detected.
+- When writing queries that aggregate metric samples, `where Has()` must always be included to avoid row count skew.
+- Do NOT use `select disctint()` with metric dimensions: aways use the optimized dimension value searches exposed by `seqcli` or the MCP tools.
+- 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.
diff --git a/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs
index 6ddd03ad..77fc5f69 100644
--- a/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs
+++ b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs
@@ -13,7 +13,7 @@
 
 namespace SeqCli.EndToEnd.Mcp;
 
-//[CliTestCase(MinimumApiVersion = "2026.1.0")]
+[CliTestCase(MinimumApiVersion = "2026.1.0")]
 public class McpMetricsBasicsTestCase : McpToolTestCase
 {
     [UsedImplicitly]
diff --git a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs
index b134cdcc..4131f1df 100644
--- a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs
+++ b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs
@@ -32,7 +32,7 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm
         Assert.Equal(0, runner.Exec("metrics dimensions", "--metric e"));
         Assert.Equal("b", runner.LastRunProcess!.Output.Trim());
         
-        Assert.Equal(0, runner.Exec("metrics dimension", "--accessor b"));
+        Assert.Equal(0, runner.Exec("metrics dimensionvalues", "--accessor b"));
         var bValues = runner.LastRunProcess!.Output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
         Assert.All(["2", "5"], value => Assert.Contains(bValues, l => l.Trim() == value));
     }
diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj
index da56b1d2..5cc1449c 100644
--- a/test/SeqCli.Tests/SeqCli.Tests.csproj
+++ b/test/SeqCli.Tests/SeqCli.Tests.csproj
@@ -9,6 +9,8 @@
       all
       runtime; build; native; contentfiles; analyzers; buildtransitive
     
+    
+    
   
   
     
diff --git a/test/SeqCli.Tests/Skills/SkillComplexityTests.cs b/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
new file mode 100644
index 00000000..a8cd08c1
--- /dev/null
+++ b/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.ML.Tokenizers;
+using Xunit;
+
+namespace SeqCli.Tests.Skills;
+
+public class SkillComplexityTests
+{
+    public static IEnumerable FindPackagedSkills()
+    {
+        var sourcePath = Path.Combine(AppContext.BaseDirectory, "Skills");
+
+        foreach (var skillSourceDirectory in Directory.EnumerateDirectories(sourcePath))
+        {
+            var skillName = Path.GetFileName(skillSourceDirectory);
+            yield return [skillName, Path.Combine(skillSourceDirectory, "SKILL.md")];
+        }        
+    }
+    
+    [Theory]
+    [MemberData(nameof(FindPackagedSkills))]
+    public void SkillIsWithinRecommendedSizeLimits(string skillName, string path)
+    {
+        const int recommendedMaxLines = 500, recommendedMaxTokens = 5000;
+        
+        // Still some work to do, here.
+        const int allowedMaxTokens = recommendedMaxTokens + 4000;
+        
+        var lines = File.ReadAllLines(path);
+        Assert.True(lines.Length < recommendedMaxLines, $"`{skillName}/SKILL.md` line count {lines.Length} exceeds the recommended {recommendedMaxLines}-line limit");
+
+        const string benchmarkModelName = "gpt-5";
+        var tokenizer = TiktokenTokenizer.CreateForModel(benchmarkModelName);
+        var tokenCount = tokenizer.CountTokens(File.ReadAllText(path));
+        Assert.True(tokenCount < allowedMaxTokens, $"`{skillName}/SKILL.md` token count {tokenCount} ({benchmarkModelName}) exceeds {allowedMaxTokens} ({recommendedMaxTokens} is the recommended limit)");
+    }
+}
\ No newline at end of file

From 4cd44a5b7506a5e6ef3c56c2667e14f8f5f2f15e Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Tue, 9 Jun 2026 15:49:20 +1000
Subject: [PATCH 90/98] Patch up skill size test

---
 test/SeqCli.Tests/Skills/SkillComplexityTests.cs | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/test/SeqCli.Tests/Skills/SkillComplexityTests.cs b/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
index a8cd08c1..11d0604c 100644
--- a/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
+++ b/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
@@ -26,10 +26,11 @@ public void SkillIsWithinRecommendedSizeLimits(string skillName, string path)
         const int recommendedMaxLines = 500, recommendedMaxTokens = 5000;
         
         // Still some work to do, here.
-        const int allowedMaxTokens = recommendedMaxTokens + 4000;
+        const int allowedMaxLines = recommendedMaxLines + 100,
+            allowedMaxTokens = recommendedMaxTokens + 4000;
         
         var lines = File.ReadAllLines(path);
-        Assert.True(lines.Length < recommendedMaxLines, $"`{skillName}/SKILL.md` line count {lines.Length} exceeds the recommended {recommendedMaxLines}-line limit");
+        Assert.True(lines.Length < allowedMaxLines, $"`{skillName}/SKILL.md` line count {lines.Length} exceeds the {allowedMaxLines} lines ({recommendedMaxLines} is the recommended limit)");
 
         const string benchmarkModelName = "gpt-5";
         var tokenizer = TiktokenTokenizer.CreateForModel(benchmarkModelName);

From d1cc30343c0358bea504176e2ee3e59cecba7f92 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Tue, 9 Jun 2026 18:35:38 +1000
Subject: [PATCH 91/98] Add a query example for exponentially bucketed
 histograms

---
 .../working-with-seq-metrics/SKILL.md         | 19 ++++++++++++++++++-
 1 file changed, 18 insertions(+), 1 deletion(-)

diff --git a/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md b/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
index e9516e8b..9e2a4190 100644
--- a/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
+++ b/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
@@ -96,7 +96,8 @@ HTTP request timing percentiles:
 select
   phist(coalesce((bucket.from + bucket.to) / 2, bucket.from, bucket.to), bucket.count, 95) as p95,
   phist(coalesce((bucket.from + bucket.to) / 2, bucket.from, bucket.to), bucket.count, 99) as p99
-from series lateral unnest(http.server.request.duration.buckets) as bucket 
+from series
+  lateral unnest(http.server.request.duration.buckets) as bucket 
 where Has(http.server.request.duration.buckets)
   and @Resource.service.name = 'cart'
   and @Scope.name = 'Microsoft.AspNetCore.Hosting'
@@ -131,6 +132,22 @@ group by time(1m)
 limit 100
 ```
 
+### `Exponential` Query Examples
+
+Commit timing percentiles:
+
+```
+select
+  phist(bucket.midpoint, bucket.count, 95) as p95,
+  phist(bucket.midpoint, bucket.count, 99) as p99
+from series
+  lateral unnest(commit_duration.buckets) as bucket 
+where Has(commit_duration.buckets)
+  and @Timestamp >= now() - 15m
+group by time(1m)
+limit 100
+```
+
 ## Gotchas
 
 - If metrics that should be _counters_ show up as _gauges_, aggregation temporality is likely to be misconfigured: danger! This can produce wildly inaccurate results if not detected.

From 69970f0aaf9105ab6a757b08be8f3e36dace26fb Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Thu, 11 Jun 2026 09:13:37 +1000
Subject: [PATCH 92/98] Update app building skill with publishing gotcha

---
 .../Skills/Resources/building-seq-plug-in-apps/SKILL.md   | 8 ++++++++
 1 file changed, 8 insertions(+)

diff --git a/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md
index 4aaadd78..b7c1ae32 100644
--- a/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md
+++ b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md
@@ -46,6 +46,10 @@ Input apps use `Seq.Input.{Name}` naming.
     
     
   
+  
+    
+    
+  
 
 ```
 
@@ -521,6 +525,10 @@ Take care that the smoke test project doesn't exit or assume completion before a
 - Default time zone: `Etc/UTC`
 - Default date/time format: `o` (ISO-8601 round-trip)
 
+## Gotchas
+
+- Seq does not resolve package dependencies when installing apps. Apps must package assembly dependencies into their own NUPKG (see CSPROJ conventions above). 
+
 ## References
 
 - [CLEF specification](https://clef-json.org) — the Compact Log Event Format (`@t`, `@mt`, `@m`, `@l`, `@x`, `@i`, `@r`)

From f427e9ce6fa86e85b943b22b1f59a7af56ec8354 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Thu, 11 Jun 2026 09:17:19 +1000
Subject: [PATCH 93/98] Fix note pointing out @sa property name

---
 src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md
index b7c1ae32..f32e1a1e 100644
--- a/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md
+++ b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md
@@ -532,7 +532,7 @@ Take care that the smoke test project doesn't exit or assume completion before a
 ## References
 
 - [CLEF specification](https://clef-json.org) — the Compact Log Event Format (`@t`, `@mt`, `@m`, `@l`, `@x`, `@i`, `@r`)
-- [Posting raw events](https://docs.datalust.co/docs/posting-raw-events) — CLEF reference including Seq trace extensions (`@tr`, `@sp`, `@ps`, `@st`, `@sc`, `@ra`, `@sk`)
+- [Posting raw events](https://docs.datalust.co/docs/posting-raw-events) — CLEF reference including Seq trace extensions (`@tr`, `@sp`, `@ps`, `@st`, `@sa`, `@ra`, `@sk`)
 - [Template syntax](https://docs.datalust.co/docs/template-syntax) — documentation for the Seq template language used in app settings
 - [seq-apps-runtime](https://github.com/datalust/seq-apps-runtime) — source code for the `Seq.Apps` API (`SeqApp`, `ISubscribeToAsync`, `ISubscribeToJsonAsync`, etc.)
 - [seqcli](https://github.com/datalust/seqcli) — source code for the `seqcli app run` command that Seq uses to host apps at runtime

From 9fd5d01f5b783899f1f941ba03f2c3b527adea1a Mon Sep 17 00:00:00 2001
From: KodrAus 
Date: Fri, 12 Jun 2026 13:15:12 +1000
Subject: [PATCH 94/98] use executing binary path for install command

---
 src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs
index 1aca84b1..58ccd2d0 100644
--- a/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs
+++ b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs
@@ -98,7 +98,7 @@ void Install()
         if (netshResult != 0)
             Console.WriteLine($"Could not add URL reservation for {listenUri}: `netsh` returned {netshResult}; ignoring");
 
-        var exePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Program.WindowsBinaryName);
+        var exePath = Environment.ProcessPath;
         var forwarderRunCmdline = $"\"{exePath}\" forwarder run --pre --storage=\"{_storagePath.StorageRootPath}\"";
 
         var binPath = forwarderRunCmdline.Replace("\"", "\\\"");

From c8d70ecc29a1e7de6ce3250e0a365c4a02830f42 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Mon, 13 Jul 2026 10:35:06 +1000
Subject: [PATCH 95/98] Final 2026.1 dependency updates

---
 README.md                                   |  2 +-
 ci.global.json                              |  2 +-
 src/Roastery/Roastery.csproj                |  2 +-
 src/SeqCli/SeqCli.csproj                    | 14 +++++++-------
 test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj |  2 +-
 test/SeqCli.Tests/SeqCli.Tests.csproj       |  4 ++--
 6 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/README.md b/README.md
index 60aca3dc..ccd3af90 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ $token = (
 )
 ```
 
-### MCP and agent skills (preview)
+### MCP and agent skills
 
 The 2026.1 preview improves support for agent-driven diagnostics workflows:
 
diff --git a/ci.global.json b/ci.global.json
index d20023b3..f071a741 100644
--- a/ci.global.json
+++ b/ci.global.json
@@ -1,5 +1,5 @@
 {
   "sdk": {
-    "version": "10.0.203"
+    "version": "10.0.301"
   }
 }
diff --git a/src/Roastery/Roastery.csproj b/src/Roastery/Roastery.csproj
index 337aac00..5af15d3a 100644
--- a/src/Roastery/Roastery.csproj
+++ b/src/Roastery/Roastery.csproj
@@ -6,7 +6,7 @@
     
 
     
-        
+        
         
     
 
diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj
index 7864a99c..39adb84c 100644
--- a/src/SeqCli/SeqCli.csproj
+++ b/src/SeqCli/SeqCli.csproj
@@ -27,20 +27,20 @@
     
   
   
-    
+    
     
-    
-    
+    
+    
     
     
     
     
     
-    
-    
+    
+    
     
-    
-    
+    
+    
     
     
     
diff --git a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
index 988cbea1..0816d85f 100644
--- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
+++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
@@ -7,7 +7,7 @@
     false
   
   
-    
+    
     
   
   
diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj
index 5cc1449c..9f76d650 100644
--- a/test/SeqCli.Tests/SeqCli.Tests.csproj
+++ b/test/SeqCli.Tests/SeqCli.Tests.csproj
@@ -3,14 +3,14 @@
     net10.0
   
   
-    
+    
     
     
       all
       runtime; build; native; contentfiles; analyzers; buildtransitive
     
     
-    
+    
   
   
     

From a74adf547b0aa6a4e0b3c87cbaf1cd681fdca245 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Mon, 13 Jul 2026 10:42:11 +1000
Subject: [PATCH 96/98] Avoid a vulnerable Microsoft.Bcl.Memory version

---
 test/SeqCli.Tests/SeqCli.Tests.csproj | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj
index 9f76d650..3f878c5e 100644
--- a/test/SeqCli.Tests/SeqCli.Tests.csproj
+++ b/test/SeqCli.Tests/SeqCli.Tests.csproj
@@ -11,6 +11,9 @@
     
     
     
+    
+    
   
   
     

From 5bf761487c23f52316c31cded97a566fd03ffee3 Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Mon, 13 Jul 2026 11:03:34 +1000
Subject: [PATCH 97/98] Fix test project package reference

---
 test/SeqCli.Tests/SeqCli.Tests.csproj | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj
index 3f878c5e..a6c6e5ac 100644
--- a/test/SeqCli.Tests/SeqCli.Tests.csproj
+++ b/test/SeqCli.Tests/SeqCli.Tests.csproj
@@ -16,7 +16,7 @@
     
   
   
-    
+    
   
   
     
@@ -35,4 +35,4 @@
       PreserveNewest
     
   
-
\ No newline at end of file
+

From 7a0d73bc8f7b4ec05ff60909a942a4fe80e72fbc Mon Sep 17 00:00:00 2001
From: Nicholas Blumhardt 
Date: Mon, 13 Jul 2026 11:04:43 +1000
Subject: [PATCH 98/98] Switched package/project reference

---
 test/SeqCli.Tests/SeqCli.Tests.csproj | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj
index a6c6e5ac..6b663585 100644
--- a/test/SeqCli.Tests/SeqCli.Tests.csproj
+++ b/test/SeqCli.Tests/SeqCli.Tests.csproj
@@ -13,10 +13,10 @@
     
     
-    
+    
   
   
-    
+