diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52a1c06..9f642330 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: @@ -22,11 +29,11 @@ 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: 9.0.x + dotnet-version: 10.0.x - name: Build and Publish env: DOTNET_CLI_TELEMETRY_OPTOUT: true @@ -38,17 +45,17 @@ 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: 9.0.x + dotnet-version: 10.0.x - name: Configure Docker run: | docker run --privileged --rm linuxkit/binfmt:bebbae0c1100ebf7bf2ad4dfb9dfd719cf0ef132 diff --git a/.gitignore b/.gitignore index fcfa8504..4e050436 100644 --- a/.gitignore +++ b/.gitignore @@ -292,3 +292,7 @@ __pycache__/ global.json .DS_Store/ + +.claude/ +.qwen/ +.agents/ diff --git a/README.md b/README.md index 8f410135..ccd3af90 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 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): @@ -55,6 +58,28 @@ $token = ( ) ``` +### MCP and agent skills + +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`. @@ -1843,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 @@ -1865,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 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 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..f2df699c 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 { @@ -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 diff --git a/ci.global.json b/ci.global.json index a3ae7002..f071a741 100644 --- a/ci.global.json +++ b/ci.global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "9.0.311" + "version": "10.0.301" } } diff --git a/dockerfiles/seqcli/linux-arm64.Dockerfile b/dockerfiles/seqcli/linux-arm64.Dockerfile index 68d201b3..8be10413 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 \ @@ -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-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/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/ExponentialHistogram.cs b/src/Roastery/Metrics/ExponentialHistogram.cs new file mode 100644 index 00000000..c5d39b22 --- /dev/null +++ b/src/Roastery/Metrics/ExponentialHistogram.cs @@ -0,0 +1,68 @@ +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; + + 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); + _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; + + public double Min => _min; + public double Max => _max; + public double Sum => _sum; + public ulong Total => _total; +} \ No newline at end of file diff --git a/src/Roastery/Metrics/PropertyNameMapping.cs b/src/Roastery/Metrics/PropertyNameMapping.cs new file mode 100644 index 00000000..28a7f28e --- /dev/null +++ b/src/Roastery/Metrics/PropertyNameMapping.cs @@ -0,0 +1,3 @@ +namespace Roastery.Metrics; + +public record struct PropertyNameMapping(string MetricDefinitions); diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs new file mode 100644 index 00000000..12a4c08a --- /dev/null +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -0,0 +1,201 @@ +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 +{ + public class Sample + { + /* + Adding new metrics: + + 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. + */ + + // `HttpRequestDuration`: histogram + public record struct HttpRequestDurationKey(string Path, int StatusCode); + public readonly Dictionary HttpRequestDuration = new(); + + // `OrderCreated`: counter + public ulong OrderCreated; + + // `OrderShipped`: counter + public ulong OrderShipped; + + static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled"); + + public IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp) + { + foreach (var (key, metric) in HttpRequestDuration) + { + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [nameof(HttpRequestDuration)] = new + { + kind = "Exponential", + unit = "ms", + description = "The time taken to fully process a request." + } + }, + new Dictionary + { + [nameof(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 + }, + [nameof(key.Path)] = key.Path, + [nameof(key.StatusCode)] = key.StatusCode + } + ); + } + + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [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 Dictionary + { + [nameof(OrderCreated)] = OrderCreated, + [nameof(OrderShipped)] = OrderShipped + } + ); + } + + static LogEvent ToLogEvent(ILogger logger, PropertyNameMapping propertyNameMapping, DateTimeOffset timestamp, Dictionary definitions, Dictionary samples) + { + 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, properties); + } + } + + // 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 RecordHttpRequestDuration(Sample.HttpRequestDurationKey key, double rawValue) + { + lock (_lock) + { + if (!_current.HttpRequestDuration.TryGetValue(key, out var metric)) + { + metric = new ExponentialHistogram(); + _current.HttpRequestDuration.Add(key, metric); + } + + metric.Record(rawValue); + } + } + + public void RecordOrderCreated() + { + lock (_lock) + { + _current.OrderCreated += 1; + } + } + + public void RecordOrderShipped() + { + lock (_lock) + { + _current.OrderShipped += 1; + } + } + + public (DateTimeOffset, Sample) Take() + { + var timestamp = DateTimeOffset.UtcNow; + + var current = new Sample(); + + lock (_lock) + { + (current, _current) = (_current, current); + } + + return (timestamp, current); + } + + 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 + { + 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; + } + }, cancellationToken); + } +} diff --git a/src/Roastery/Program.cs b/src/Roastery/Program.cs index 00f587f5..acef4206 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; @@ -15,22 +17,35 @@ 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), (timestamp, sample, ct) => + { + foreach (var evt in sample.ToLogEvents(webApplicationLogger, propertyNameMapping, timestamp)) + { + webApplicationLogger.Write(evt); + } + + 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([ - new OrdersController(logger, database), - new ProductsController(logger, database) + new OrdersController(logger, metrics, database), + new ProductsController(logger, metrics, database) ], webApplicationLogger)))))); var agents = new List(); @@ -46,5 +61,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/Roastery.csproj b/src/Roastery/Roastery.csproj index ea919e7c..5af15d3a 100644 --- a/src/Roastery/Roastery.csproj +++ b/src/Roastery/Roastery.csproj @@ -1,13 +1,13 @@ - net9.0 + net10.0 enable - - + + 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) diff --git a/src/Roastery/Web/RequestLoggingMiddleware.cs b/src/Roastery/Web/RequestLoggingMiddleware.cs index f0cdb784..3219152a 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.RecordHttpRequestDuration(new RoasteryMetrics.Sample.HttpRequestDurationKey(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.RecordHttpRequestDuration(new RoasteryMetrics.Sample.HttpRequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); + return new HttpResponse(statusCode, "An error occurred."); } } 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/AppContainer.cs b/src/SeqCli/Apps/Hosting/AppContainer.cs index 72184492..8a58c2bd 100644 --- a/src/SeqCli/Apps/Hosting/AppContainer.cs +++ b/src/SeqCli/Apps/Hosting/AppContainer.cs @@ -15,12 +15,13 @@ using System; using System.Globalization; using System.IO; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Newtonsoft.Json; 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; @@ -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 NonZeroHex = NonZeroHexRegex(); + 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 && NonZeroHex.IsMatch(id); + } + public void StartPublishing(TextWriter inputWriter) { if (_seqApp is IPublishJson pjson) @@ -169,4 +197,7 @@ public void StopPublishing() if (_seqApp is IPublishJson pjson) pjson.Stop(); } -} \ No newline at end of file + + [GeneratedRegex("^0*[1-9a-f][0-9a-f]*$")] + private static partial Regex NonZeroHexRegex(); +} diff --git a/src/SeqCli/Apps/Hosting/AppHost.cs b/src/SeqCli/Apps/Hosting/AppHost.cs index 09a5c62e..aaea1ac0 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. @@ -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/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..d715cc70 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. @@ -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/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..24508499 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. @@ -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/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 437fe98a..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. @@ -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/ApiKey/ListCommand.cs b/src/SeqCli/Cli/Commands/ApiKey/ListCommand.cs index 3e2a9dbf..63dffd5e 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. @@ -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 b3789c91..23254ca9 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. @@ -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 56909bbb..3844e351 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. @@ -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/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/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/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..02ec9a0d --- /dev/null +++ b/src/SeqCli/Cli/Commands/CommandAliases.cs @@ -0,0 +1,95 @@ +// 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; + +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/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/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 89dc94c1..3f2987d1 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. @@ -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 c16dc0a9..52566c9e 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. @@ -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 4d78f61b..bbbd7f59 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. @@ -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; @@ -59,7 +58,7 @@ public RenderCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); _connection = Enable(); } @@ -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) @@ -181,7 +170,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}')"); @@ -202,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/Cli/Commands/Events/DeleteCommand.cs b/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs new file mode 100644 index 00000000..8ae6927b --- /dev/null +++ b/src/SeqCli/Cli/Commands/Events/DeleteCommand.cs @@ -0,0 +1,78 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using 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/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/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..b10f245d 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. @@ -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 4710d24f..99a788ed 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. @@ -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/Forwarder/InstallCommand.cs b/src/SeqCli/Cli/Commands/Forwarder/InstallCommand.cs index fadb3a80..58ccd2d0 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; @@ -28,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 { @@ -99,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("\"", "\\\""); 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() 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..8b1b62a6 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. @@ -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 27517e29..0b970622 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. @@ -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/IngestCommand.cs b/src/SeqCli/Cli/Commands/IngestCommand.cs index d356212e..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. @@ -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/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/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/Mcp/InstallCommand.cs b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs new file mode 100644 index 00000000..8b1bed91 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/InstallCommand.cs @@ -0,0 +1,52 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading.Tasks; +using SeqCli.Mcp; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Mcp; + +[Command("mcp", "install", "Install or update the Seq MCP server for an agent", + Example = "seqcli mcp install --global --agent claude")] +class InstallCommand : Command +{ + bool _global; + string? _agent; + string? _profile; + + public InstallCommand() + { + Options.Add( + "g|global", + "Install for the current user globally; the default is to install into the current project directory", + _ => _global = true); + + Options.Add( + "a=|agent=", + "The agent name to install the MCP server for; the default is the generic name `agents`", + t => _agent = ArgumentString.Normalize(t)); + + Options.Add( + "profile=", + "A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used", + v => _profile = ArgumentString.Normalize(v)); + } + + protected override Task Run() + { + McpServerInstaller.Install(_agent?.ToLowerInvariant(), _global, _profile); + return Task.FromResult(0); + } +} diff --git a/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs new file mode 100644 index 00000000..3d857d51 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Mcp/RunCommand.cs @@ -0,0 +1,89 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using SeqCli.Api; +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; + +[Command("mcp", "run", "Run an MCP (Model Context Protocol) server on STDIO")] +class RunCommand: Command +{ + readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; + bool _debug; + + public RunCommand() + { + _connection = Enable(); + _storagePath = Enable(); + Options.Add("debug", "Write diagnostic messages from the MCP server back through the connection", + _ => _debug = true); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + + if (_debug) + { + Log.Logger = new LoggerConfiguration() + .Enrich.WithProperty("Application", "seqcli mcp run") + .WriteTo.Seq(config.Connection.ServerUrl, apiKey: config.Connection.DecodeApiKey(config.Encryption.DataProtector())) + .CreateLogger(); + + Log.Information("Seq MCP server starting up"); + } + + try + { + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSerilog(); + builder.Services.AddSingleton(_ => SeqConnectionFactory.Connect(_connection, config)); + builder.Services.AddSingleton(); + builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools([ + typeof(SearchTools), + typeof(MetricsTools), + typeof(QueryTools), + typeof(SignalTools) + ]); + + await builder.Build().RunAsync(); + } + catch (Exception ex) + { + Log.Fatal(ex, "Unhandled exception"); + return 1; + } + finally + { + await Log.CloseAndFlushAsync(); + } + return 0; + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs new file mode 100644 index 00000000..f4da9047 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs @@ -0,0 +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; + +[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; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _accessor; + int _count = 30; + bool _trace; + + public DimensionValuesCommand() + { + 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 new file mode 100644 index 00000000..9f085c28 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionsCommand.cs @@ -0,0 +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; + +[Command("metrics", "dimensions", "List the dimensions associated with 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=", + "A metric name, for example `hats-sold` or `http.request.duration`; omit to list dimensions for all metrics", + 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 new file mode 100644 index 00000000..5cc1c3ee --- /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 512")] +class SearchCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly DateRangeFeature _range; + readonly StoragePathFeature _storagePath; + string? _filter; + readonly List _groups = []; + int _count = 30; + 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.Name ?? 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[] { "Name", "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 diff --git a/src/SeqCli/Cli/Commands/Node/HealthCommand.cs b/src/SeqCli/Cli/Commands/Node/HealthCommand.cs index 5a3fcc37..54bcf7a9 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; @@ -107,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/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/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..6dd7db67 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. @@ -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; @@ -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,11 +37,11 @@ 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(); - _output = Enable(); + _output = Enable(new OutputFormatFeature(supportNative: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); _connection = Enable(); @@ -63,19 +61,9 @@ 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(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - - // Some friendlier JSON output is definitely possible here - Console.WriteLine(JsonConvert.SerializeObject(result)); - } - else - { - var result = await connection.Data.QueryCsvAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); - output.WriteCsv(result); - } - - return 0; + var result = await connection.Data.TryQueryAsync(_query, _range.Start, _range.End, _signal.Signal, timeout: timeout, trace: _trace); + output.WriteQueryResult(result); + + return string.IsNullOrWhiteSpace(result.Error) ? 0 : 1; } } \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs b/src/SeqCli/Cli/Commands/RetentionPolicy/CreateCommand.cs index 5947752d..4c9c5bdb 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, ignoreCase: true, 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/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/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index adea7806..a9b1e049 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. @@ -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.Levels; -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; } } - - 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}}}"); - } - - LogEventProperty CreateProperty(string name, object value) - { - return LogEventPropertyFactory.SafeCreate(name, CreatePropertyValue(value)); - } - - 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/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/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..46f67513 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. @@ -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 75a5a397..89189b33 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. @@ -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/Skills/InstallCommand.cs b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs new file mode 100644 index 00000000..a0278f55 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Skills/InstallCommand.cs @@ -0,0 +1,46 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Threading.Tasks; +using SeqCli.Skills; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Skills; + +[Command("skills", "install", "Install or update Seq agent skills", + Example = "seqcli skills install --global --agent claude")] +class InstallCommand : Command +{ + bool _global; + string? _agent; + + public InstallCommand() + { + Options.Add( + "g|global", + "Install skills 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`", + t => _agent = ArgumentString.Normalize(t)); + } + + protected override Task Run() + { + SkillInstaller.Install(_agent?.ToLowerInvariant(), _global); + return Task.FromResult(0); + } +} \ No newline at end of file diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 26510b84..3da0b991 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. @@ -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/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/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..6bb8f885 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. @@ -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 8616fafa..dcfe60dd 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. @@ -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/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 new file mode 100644 index 00000000..cad65ef4 --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/CreateCommand.cs @@ -0,0 +1,111 @@ +// 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 System.Threading.Tasks; +using Seq.Api.Model.Shared; +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..23446e52 --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/ListCommand.cs @@ -0,0 +1,55 @@ +// 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.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..7f50b62b --- /dev/null +++ b/src/SeqCli/Cli/Commands/View/RemoveCommand.cs @@ -0,0 +1,69 @@ +// 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.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..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; @@ -37,7 +36,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 +64,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/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/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/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/Cli/Features/PropertiesFeature.cs b/src/SeqCli/Cli/Features/PropertiesFeature.cs index 3000952a..d55630ed 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. @@ -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/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..b9c70dcf 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. @@ -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/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/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..fff15271 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -1,63 +1,76 @@ using System; -using System.Collections.Generic; 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, Func stringify, 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) + var firstCol = true; + foreach (var value in row) { - 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}`."); + WriteCell(output, theme, value, stringify, ref firstCol, isHeadingRow: first); } + first = false; + output.WriteLine(); + }); + } + + static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, Func stringify, 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); + + var valueAsString = stringify(value); + + 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/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/ServiceProcess/SeqCliForwarderWindowsService.cs b/src/SeqCli/Forwarder/ServiceProcess/SeqCliForwarderWindowsService.cs index 6b83458f..21b21f6b 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. @@ -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/Forwarder/Storage/BufferReader.cs b/src/SeqCli/Forwarder/Storage/BufferReader.cs index 7a4721c9..8c902905 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.File.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.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.File.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; } @@ -245,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 @@ -261,17 +314,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/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 5cc37a69..5a101b2a 100644 --- a/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs +++ b/src/SeqCli/Forwarder/Storage/BufferReaderChunkExtents.cs @@ -12,12 +12,35 @@ // 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; + } + + /// + /// 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); } 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 07a3ab85..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. @@ -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/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index 337e5008..c5738604 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. @@ -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/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/Levels/LevelMapping.cs b/src/SeqCli/Mapping/LevelMapping.cs similarity index 98% rename from src/SeqCli/Levels/LevelMapping.cs rename to src/SeqCli/Mapping/LevelMapping.cs index f74b7523..ff79087b 100644 --- a/src/SeqCli/Levels/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. @@ -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..fe104666 --- /dev/null +++ b/src/SeqCli/Mapping/MetricsMapping.cs @@ -0,0 +1,8 @@ +using System; + +namespace SeqCli.Mapping; + +public static class MetricsMapping +{ + internal static readonly string SurrogateDefinitionsProperty = $"_SeqcliMetricDefinitions_{Guid.NewGuid():N}"; +} diff --git a/src/SeqCli/Mcp/Data/QueryResultHelper.cs b/src/SeqCli/Mcp/Data/QueryResultHelper.cs new file mode 100644 index 00000000..7d86837b --- /dev/null +++ b/src/SeqCli/Mcp/Data/QueryResultHelper.cs @@ -0,0 +1,127 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Seq.Api.Model.Data; + +namespace SeqCli.Mcp.Data; + +static class QueryResultHelper +{ + /// + /// Convert into a flat table. Seq reduces browser-side processing and optimizes + /// response sizes by constructing result trees for some grouped/time-sliced query results. + /// + public static void Flatten(QueryResultPart result, Action> writeRow) + { + if (result.Error != null) + return; + + if (result.Rows != null) + { + writeRow(result.Columns!); + foreach (var row in result.Rows) + { + writeRow(row); + } + } + else if (result.Slices != null) + { + writeRow(new object[] {"time"}.Concat(result.Columns!)); + + var empty = result.Columns!.Select(_ => "").ToArray(); + foreach (var slice in result.Slices) + { + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(row)); + } + if (!any) + { + writeRow(new object[] { DateTimeOffset.Parse(slice.Time).UtcDateTime }.Concat(empty)); + } + } + } + else if (result.Series != null) + { + writeRow(MergeColumns(result.Columns!, result.Series.FirstOrDefault())); + foreach (var series in result.Series) + { + foreach (var slice in series.Slices) + { + var empty = result.Columns!.Take(series.Key.Length).Select(_ => (object?)null).ToArray(); + var any = false; + foreach (var row in slice.Rows) + { + any = true; + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(row)); + } + if (!any) + { + writeRow(series.Key.Concat([DateTimeOffset.Parse(slice.Time).UtcDateTime]).Concat(empty)); + } + } + } + } + else + { + throw new NotImplementedException("Query result set does not conform to any expected pattern."); + } + } + + static IEnumerable MergeColumns(string[] columns, TimeseriesPart? firstSeries) + { + if (firstSeries == null) + yield break; + + var i = 0; + for (; i < firstSeries.Key.Length; ++i) + { + yield return columns[i]; + } + + yield return "time"; + + for (; i < columns.Length; ++i) + { + yield return columns[i]; + } + } + + 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/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs new file mode 100644 index 00000000..52bc4596 --- /dev/null +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -0,0 +1,176 @@ +// 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"; + + static readonly IReadOnlyDictionary KnownAgents = + new Dictionary + { + ["claude"] = new( + global => global + ? Path.Combine(UserProfile, ".claude.json") + : Path.Combine(Environment.CurrentDirectory, ".mcp.json"), + "mcpServers"), + + ["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`."), + "mcpServers"), + + ["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"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".qwen", + "settings.json"), + "mcpServers"), + + ["gemini"] = new( + global => Path.Combine( + global ? UserProfile : Environment.CurrentDirectory, + ".gemini", + "settings.json"), + "mcpServers"), + + ["zed"] = new( + global => global + ? Path.Combine(XdgConfigHome, "zed", "settings.json") + : Path.Combine(Environment.CurrentDirectory, ".zed", "settings.json"), + "context_servers"), + + ["amazonq"] = new( + global => global + ? Path.Combine(UserProfile, ".aws", "amazonq", "mcp.json") + : Path.Combine(Environment.CurrentDirectory, ".amazonq", "mcp.json"), + "mcpServers"), + + ["roo"] = new( + global => global + ? throw new NotSupportedException( + "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"] = 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"), + }; + + 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); + + // 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, + }; + + 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); + } + + static AgentTarget Unsupported(string message) => + new(_ => throw new NotSupportedException(message), "mcpServers"); + + 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); + + 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/Mcp/McpSession.cs b/src/SeqCli/Mcp/McpSession.cs new file mode 100644 index 00000000..8a102b32 --- /dev/null +++ b/src/SeqCli/Mcp/McpSession.cs @@ -0,0 +1,129 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Threading; +using Seq.Api.Model.Events; +using System; +using System.Linq; +using SeqCli.Mcp.Schema; + +namespace SeqCli.Mcp; + +class McpSession +{ + public TimeSpan DataToolCallTimeout { get; } = TimeSpan.FromSeconds(45); + + readonly Lock _sync = new(); + int _nextId = 1; + readonly Dictionary _resultIdToEventId = new(); + readonly Dictionary _eventIdToResult = new(); + + public void Clear() + { + lock (_sync) + { + _resultIdToEventId.Clear(); + _eventIdToResult.Clear(); + // Note that `_nextId` is intentionally preserved. + } + } + + public string ImportSearchResult(EventEntity evt) + { + lock (_sync) + { + if (_eventIdToResult.TryGetValue(evt.Id, out var existing)) + return FormatResultId(existing.Item1); + var resultId = _nextId; + _nextId += 1; + _resultIdToEventId.Add(resultId, evt.Id); + _eventIdToResult.Add(evt.Id, (resultId, evt)); + return FormatResultId(resultId); + } + } + + internal static string FormatResultId(int resultId) + { + return "R" + resultId.ToString("X5"); + } + + internal static bool TryParseResultId(string formatted, [NotNullWhen(true)] out int? resultId) + { + if (!formatted.StartsWith('R') || !int.TryParse(formatted[1..], NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out var parsed)) + { + resultId = null; + return false; + } + + resultId = parsed; + return true; + } + + public bool TryGetSearchResult(string resultId, [NotNullWhen(true)] out EventEntity? result, + [NotNullWhen(false)] out string? error) + { + if (!TryParseResultId(resultId, out var parsed)) + { + result = null; + error = + "The result id is not correctly formatted; result ids are strings beginning with `R`, followed by a short character string."; + return false; + } + + lock (_sync) + { + if (!_resultIdToEventId.TryGetValue(parsed.Value, out var eventId)) + { + result = null; + error = + "A matching result wasn't found among recent searches. Try retrieving a fresh result id by searching again (using a very narrow time range if possible)."; + return false; + } + + if (!_eventIdToResult.TryGetValue(eventId, out var pair)) + throw new InvalidOperationException("Missing result mapping."); + + result = pair.Item2; + error = null; + return true; + } + } + + public IEnumerable EnumerateUserPropertyNames(CancellationToken cancellationToken) + { + List all; + lock (_sync) + { + all = _eventIdToResult.Values.Select(pair => pair.Item2).ToList(); + } + + var seen = new HashSet(); + foreach (var evt in all) + { + cancellationToken.ThrowIfCancellationRequested(); + + foreach (var accessor in EventEntitySchema.EnumeratePropertyAccessorPaths(evt)) + { + if (seen.Add(accessor)) + { + yield return accessor; + } + } + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Mcp/Schema/EventEntitySchema.cs b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs new file mode 100644 index 00000000..1a72bc75 --- /dev/null +++ b/src/SeqCli/Mcp/Schema/EventEntitySchema.cs @@ -0,0 +1,61 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Events; +using SeqCli.Output; + +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 = NativeFormatter.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/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..4eac5be2 --- /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, 512)] + 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..444de98e --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Query/QueryTools.cs @@ -0,0 +1,90 @@ +// 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); + } + + 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); + + 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/SearchTools.cs b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs new file mode 100644 index 00000000..42343c8a --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs @@ -0,0 +1,231 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Seq.Api; +using Seq.Api.Client; +using Seq.Api.Model.Events; +using Seq.Api.Model.Signals; +using Seq.Syntax.Templates; +using SeqCli.Mapping; +using SeqCli.Output; +using SeqCli.Signals; +using Serilog; +using Serilog.Events; +using NativeFormatter = SeqCli.Output.NativeFormatter; + +// ReSharper disable UnusedMember.Global + +namespace SeqCli.Mcp.Tools.Search; + +[McpServerToolType] +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_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` " + + "skill when calling this tool.")] + [return: Description("Search results and status information.")] + public async Task SearchEventsAsync( + [Description("The maximum number of events to return.")] + [Range(1, 1000)] + int limit, + [Description("A Seq search expression evaluated over event properties.")] + string? predicate = null, + [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)) + { + if (!predicate.Contains("@Timestamp", StringComparison.OrdinalIgnoreCase) && + !predicate.Contains("@Id", StringComparison.OrdinalIgnoreCase) && + !predicate.Contains("@TraceId", StringComparison.OrdinalIgnoreCase)) + { + 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); + } + + var strict = await connection.Expressions.ToStrictAsync(predicate, cancellationToken); + if (strict.MatchedAsText) + { + return McpResults.SimpleText($"The search expression was rejected by the Seq server. {strict.ReasonIfMatchedAsText}", + isError: true); + } + } + + SignalExpressionPart? parsedSignalExpression = null; + if (!string.IsNullOrWhiteSpace(signal)) + parsedSignalExpression = SignalExpressionParser.ParseExpression(signal); + + var resultsLock = new Lock(); + string? error = null; + var results = new List(); + var timeout = Task.Delay(session.DataToolCallTimeout, cancellationToken); + using var cancelEnumerate = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var cancelEnumerateToken = cancelEnumerate.Token; + var enumerate = Task.Run(async () => + { + try + { + await foreach (var evt in connection.Events.EnumerateAsync( + filter: predicate, + count: limit, + render: true, + signal: parsedSignalExpression, + cancellationToken: cancelEnumerateToken)) + { + lock (resultsLock) + { + results.Add(evt); + } + } + } + catch (Exception ex) + { + if (ex.GetBaseException() is not OperationCanceledException) + { + Log.Error(ex, "Exception thrown during search result enumeration"); + } + + lock (resultsLock) + { + error = ex.GetBaseException() is SeqApiException ? ex.GetBaseException().Message : "The Seq API call failed."; + } + } + }, cancellationToken); + + var completed = await Task.WhenAny(enumerate, timeout) == enumerate; + await cancelEnumerate.CancelAsync(); + + EventEntity[] takenResults; + string? takenError; + lock (resultsLock) + { + takenResults = results.ToArray(); + takenError = error; + } + + string resultSetStatus; + + var reachedLimit = takenResults.Length == limit; + if (reachedLimit) + { + resultSetStatus = $"Showing the most recent {limit} matching event(s):"; + } + else if (takenError != null) + { + resultSetStatus = takenResults.Length == 0 ? + $"The search failed. {takenError}" : + $"The search failed after retrieving {takenResults.Length} matching event(s). {takenError}"; + } + else if (completed) + { + resultSetStatus = takenResults.Length == 0 ? + "No events matched the search expression." : + $"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 = 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); + } + + 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_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_events` tool.")] + // ReSharper disable once InconsistentNaming + string result_id) + { + if (!session.TryGetSearchResult(result_id, out var result, out var error)) + { + return Task.FromResult(McpResults.SimpleText(error, isError: true)); + } + + var resultText = new StringWriter(); + NativeFormatter.WriteEvent(resultText, result); + + return Task.FromResult(McpResults.SimpleText(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. " + + "Critically important for task accuracy.")] + [return: Description("A list containing Seq syntax-formatted property names.")] + public Task InspectSchemaAsync(CancellationToken cancellationToken) + { + return Task.FromResult(session.EnumerateUserPropertyNames(cancellationToken).OrderBy(n => n).ToArray()); + } +} diff --git a/src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs b/src/SeqCli/Mcp/Tools/Signals/SignalSummary.cs new file mode 100644 index 00000000..0f194e47 --- /dev/null +++ b/src/SeqCli/Mcp/Tools/Signals/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.Signals; + +[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 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/src/SeqCli/Output/NativeFormatter.cs b/src/SeqCli/Output/NativeFormatter.cs new file mode 100644 index 00000000..79017ce5 --- /dev/null +++ b/src/SeqCli/Output/NativeFormatter.cs @@ -0,0 +1,275 @@ +// Copyright © Datalust and contributors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using Seq.Api.Model.Data; +using Seq.Api.Model.Events; +using Seq.Api.Model.Shared; +using SeqCli.Mcp.Data; +using SeqCli.Syntax; + +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 NativeFormatter +{ + static readonly object UndefinedValue = new(); + + [GeneratedRegex("^[_a-zA-Z][_a-zA-Z0-9]*$")] + private static partial Regex IdentifierRegex(); + + static readonly HashSet Keywords = new(StringComparer.OrdinalIgnoreCase) + { + "and", "ci", "else", "false", "if", "in", "is", "let", "like", "not", "null", "or", "then", "true", + "analyze", "as", "asc", "by", "desc", "explain", "for", "from", "group", "having", "into", "lateral", + "limit", "lower", "order", "select", "where" + }; + + public static string MakeIdentifier(string prefixPath, string propertyName, bool prefixIsOptional) + { + if (IdentifierRegex().IsMatch(propertyName)) + { + if (prefixIsOptional && !Keywords.Contains(propertyName)) + return propertyName; + return $"{prefixPath}.{propertyName}"; + } + + var sw = new StringWriter(); + WriteValue(sw, propertyName); + return $"{prefixPath}[{sw}]"; + } + + public static void WriteEvent(TextWriter output, EventEntity evt) + { + WriteObject( + output, + true, + ("@Id", evt.Id), + ("@Timestamp", DateTimeOffset.Parse(evt.Timestamp).UtcDateTime), + ("@Level", evt.Level ?? "Information"), + ("@Message", evt.RenderedMessage), + ("@MessageTemplate", ReconstructTemplate(evt.MessageTemplateTokens)), + ("@EventType", ParseEventType(evt.EventType)), + ("@Exception", evt.Exception ?? UndefinedValue), + ("@Elapsed", evt.Elapsed ?? UndefinedValue), + ("@TraceId", evt.TraceId ?? UndefinedValue), + ("@SpanId", evt.SpanId ?? UndefinedValue), + ("@SpanKind", evt.SpanKind ?? UndefinedValue), + ("@Start", evt.Start != null ? DateTimeOffset.Parse(evt.Start).UtcDateTime : UndefinedValue), + ("@ParentId", evt.ParentId ?? UndefinedValue), + ("@Properties", evt.Properties?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Properties)) : UndefinedValue), + ("@Scope", evt.Scope?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Scope)) : UndefinedValue), + ("@Resource", evt.Resource?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Resource)) : UndefinedValue), + ("@Definitions", evt.Definitions?.Count > 0 ? new Action(w => WritePropertiesObject(w, evt.Definitions)) : UndefinedValue) + ); + } + + public static void WriteValue(TextWriter output, object? value) + { + if (value == UndefinedValue) + { + // This should never occur, but works in case it becomes necessary. + output.Write("@Undefined"); + return; + } + + switch (value) + { + case null: + output.Write("null"); + return; + case true: + output.Write("true"); + return; + case false: + output.Write("false"); + return; + } + + if (value is string s) + { + output.Write('\''); + output.Write(s.Replace("'", "''")); + output.Write('\''); + return; + } + + if (value is decimal + or double or float or Half + or byte or ushort or uint or ulong or UInt128 or + sbyte or short or int or long or Int128) + { + output.Write(((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + return; + } + + if (value is TimeSpan ts) + { + output.Write(DurationMoniker.FromTimeSpan(ts)); + return; + } + + if (value is DateTime dt) + { + output.Write($"DateTime('{dt:O}')"); + return; + } + + if (value is JArray ja) + { + var first = true; + output.Write('['); + foreach (var element in ja) + { + if (first) + first = false; + else + output.Write(", "); + WriteValue(output, element); + } + output.Write(']'); + return; + } + + if (value is JObject jo) + { + WriteObject(output, false, jo.Properties().Select(p => (p.Name, (object?)p.Value))); + return; + } + + if (value is JValue jt) + { + WriteValue(output, jt.Value); + return; + } + + WriteValue(output, value.ToString()); + } + + static void WriteMemberName(TextWriter output, string name) + { + if (IdentifierRegex().IsMatch(name)) + { + output.Write(name); + } + else + { + WriteValue(output, name); + } + } + + static void WritePropertiesObject(TextWriter output, List members) + { + WriteObject(output, false, members.Select(m => (m.Name, (object?)m.Value))); + } + + static uint ParseEventType(string dollarPrefixedHex) + { + return uint.Parse(dollarPrefixedHex.TrimStart('$'), NumberStyles.HexNumber); + } + + static string ReconstructTemplate(IEnumerable tokens) + { + return string.Concat(tokens.Select(t => + t.RawText ?? + t.Text?.Replace("{", "{{").Replace("}", "}}") ?? + $"{{{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 WriteQueryResult(TextWriter output, QueryResultPart result) + { + if (!string.IsNullOrWhiteSpace(result.Error)) + { + QueryResultHelper.WriteErrorResult(output, 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); + } + } + 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..affa48ef --- /dev/null +++ b/src/SeqCli/Output/OutputFormat.cs @@ -0,0 +1,316 @@ +// 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; +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; + + 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}"; + + 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 WriteEntity(Entity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var jo = JObject.FromObject( + entity, + _serializer); + + 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 = value is ICollection and not (IDictionary or JToken) ? + (JToken)JArray.FromObject(value, _serializer) : + JObject.FromObject(value, _serializer); + + // 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(Stringify(value)); + } + else + { + NativeFormatter.WriteValue(Console.Out, value); + Console.WriteLine(); + } + } + + 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) + { + WriteObject(result); + } + else if (Native) + { + NativeFormatter.WriteQueryResult(Console.Out, result); + } + else + { + CsvWriter.WriteQueryResult(result, Stringify, Theme, Console.Out); + } + } + + 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); + } + } + + 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() ?? "" + }; + } +} diff --git a/src/SeqCli/Output/OutputFormatter.cs b/src/SeqCli/Output/OutputFormatter.cs index 9c0c2914..43ff994b 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}, ..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/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/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/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/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/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 365a490a..1362716f 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. @@ -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; @@ -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/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..5363b98f 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. @@ -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/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 b3fc062f..a54f0a28 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), cancellationToken); await logger.DisposeAsync(); await ship; } diff --git a/src/SeqCli/SeqCli.csproj b/src/SeqCli/SeqCli.csproj index 1b417cd2..39adb84c 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 @@ -19,37 +19,37 @@ true - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - + + + + + + + - - + + - - - - - - - - + + + + + + + + - + + + + 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/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..f32e1a1e --- /dev/null +++ b/src/SeqCli/Skills/Resources/building-seq-plug-in-apps/SKILL.md @@ -0,0 +1,540 @@ +--- +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)
+
+## 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`)
+- [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
+- [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)
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..fc7604cf
--- /dev/null
+++ b/src/SeqCli/Skills/Resources/seq-search-and-query/SKILL.md
@@ -0,0 +1,374 @@
+---
+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 log events and spans.
+
+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.
+
+## 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. Use signals identified in (1) where appropriate, event search and query supports them.
+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 the path to being confidently wrong!
+
+## Event Data Model
+
+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 |
+|---|---|---|
+| `@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. 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. 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. `@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.
+
+| 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 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. 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`. 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 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`. 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`. 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. 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 `+`, `-`, `*`, `/` | 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 `%` | 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 `=` | 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. |
+
+## Aggregate Functions
+
+`select` queries 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)` |
+| `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)` |
+
+## Base Grammar
+
+```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 Grammar
+
+```ebnf
+Expr = Disjunction ;
+Disjunction = Conjunction , { 'or' , Conjunction } ;
+Conjunction = Comparison , { 'and' , Comparison } ;
+Comparison = Comparand , { comparison_op , Comparand , [ 'ci' ] } ;
+comparison_op = 'not' , 'like'
+   | 'like'
+   | 'not' , 'in'
+   | 'in'
+   | '<=' | '<>' | '<'
+   | '>=' | '>'
+   | '=' ;
+Comparand = Term , { ( '+' | '-' ) , Term }  ;
+Term = InnerTerm , { ( '*' | '/' | '%' ) , InnerTerm } ;
+InnerTerm = Operand , { '^' , Operand } ;
+
+Operand = ( unary_op , Operand | Path ) , [ 'is' , null_test ] ;
+unary_op = '-' | 'not' ;
+null_test = 'null' | 'not' , 'null' ;
+Path = Factor , { path_step } ;
+path_step = '.' , identifier
+   | '[' , ( wildcard | Expr ) , ']' ;
+wildcard = '?' | '*' ;
+Factor = '(' , Expr , ')'
+   | Item ;
+Item = Property
+   | Literal
+   | Function
+   | ArrayLiteral
+   | ObjectLiteral
+   | Conditional
+   | Block
+   | Lambda
+   | Variable ;
+Property = built_in_identifier
+   | identifier ;   (* when not followed by '(' *)
+Literal = string_literal
+   | number
+   | hex_number
+   | duration
+   | regular_expression
+   | 'true'
+   | 'false'
+   | 'null' ;
+Function = function_name , '(' , arg_list , ')' , [ 'ci' ] ;
+function_name = identifier
+   | 'and' | 'not' | 'or' ;
+arg_list = '*'   (* only valid for count(*) *)
+   | [ Expr , { ',' , Expr } ] ;
+ArrayLiteral = '[' , [ Expr , { ',' , Expr } ] , ']' ;
+ObjectLiteral = '{' , [ ObjectMember , { ',' , ObjectMember } ] , '}' ;
+ObjectMember  = ( identifier | string_literal ) , ':' , Expr ;
+Conditional = 'if' , Expr , 'then' , Expr , 'else' , Expr ;
+Block = 'let' , '|' , Binding , { ',' , Binding } , '|' , Expr ;
+Binding = identifier , ':' , Expr ;
+Lambda = '|' , [ identifier , { ',' , identifier } ] , '|' , Expr ;
+Variable = variable ;
+```
+
+### Query Grammar
+
+```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' , 'unnest' , '(' , Expr , ')' , 'as' , identifier ;
+WhereClause = 'where' , Expr ;
+GroupByClause = 'group' , 'by' , Grouping , { ',' , Grouping } ;
+Grouping = TimeGrouping
+   | Expr , [ 'ci' ] , [ 'as' , identifier ] ;
+TimeGrouping  = 'time' , '(' , duration , ')' ;
+HavingClause = 'having' , Expr ;
+OrderByClause = 'order' , 'by' , Ordering , { ',' , Ordering } ;
+Ordering = TimeOrdering
+   | identifier , [ 'ci' ] , [ 'asc' | 'desc' ] ; (* identifier must be a selected column or group key alias *)
+TimeOrdering  = 'time' ;
+LimitClause = 'limit' , natural ;
+ForClause = 'for' , ForOption , { ',' , ForOption } ;
+ForOption = identifier , [ '(' , [ Expr , { ',' , Expr } ] , ')' ] ;
+```
+
+Keywords are case-insensitive.
+
+## Example Expressions
+
+| Example | Purpose |
+|--------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
+| `@Timestamp >= Now() - 10m` | Match events that occurred in the last ten minutes. |
+| `@TraceId = '0af7651916cd43dd8448eb211c80319c'` | Match all events (both spans and log events) belonging to the given trace. |
+| `Has(@Start)` | Match all spans (excludes log events). |
+| `@Message like '%overflow%' ci or @Exception like '%overflow%' ci` | Given a piece of text, find events with that text in their message or exception/stack trace, 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. |
+| `@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 |
+
+## Query Example
+
+Grouped query with ordering:
+
+```
+select count(*)
+from stream
+group by @Resource.service.name as service
+order by service
+```
+
+## Tracing Tactics
+
+Reconstruct a trace in execution (start-time) order:
+
+```
+select
+   @SpanId as span_id,
+   @ParentId as parent_id,
+   @Resource.service.name as service,
+   @Message as span_name,
+   @SpanKind as kind,
+   @Start as start,                    -- raw ticks — order by THIS column
+   ToIsoString(@Start) as start_iso,   -- readable copy, for display only
+   TotalMilliseconds(@Elapsed) as ms
+from stream
+where @TraceId = '0af7651916cd43dd8448eb211c80319c' and Has(@Start)
+order by start asc
+limit 1000 -- traces can be large; if the result looks truncated, raise this
+```
+
+This orders rows by start time; it does NOT build the hierarchy. The call tree is assembled from `parent_id` (each row's 
+`@ParentId` = its parent's `@SpanId`; the root's is null).
+
+Note that you'll need to use the "retrieve a specific trace span" search recipe to see more about a span appearing in
+these results.
+
+Rank services by span latency over a window:
+
+```
+select
+   count(*) as spans,
+   Round(TotalMilliseconds(percentile(@Elapsed, 95)), 2) as p95_ms,
+   Round(TotalMilliseconds(max(@Elapsed)), 2) as max_ms
+from stream
+where @Timestamp >= now() - 30m and Has(@Start)
+group by @Resource.service.name as service     -- group key: alias it, do NOT put it in the select list
+having spans > 50                              -- filter groups by the select alias, NOT by count(*) directly
+order by p95_ms desc                           -- order by a selected aggregate's alias
+limit 100
+```
+
+## 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.
+ - To order by group keys, apply an alias with `group by  as ` and use `order by `. Never add the group key to the `select` list, this will fail.
+ - OpenTelemetry dotted property names correspond to property accessor paths in Seq, so `@Resource.service.name` and `http.response.status_code` are written exactly like this.
+ - **Never** put a dotted OTel name inside `[...]`. `@Resource['a.b.c']` is a single literal key (almost always undefined); use `@Resource.a.b.c` for path navigation.
+ - Seq expression literals are not JSON, take care to use the Seq expression syntax when formatting literal values.
+ - Seq queries are not SQL. Don't expect standard SQL syntax, operators, or semantics to apply, always use the grammar and built-ins described above.
+ - Seq searches work backwards through the event stream and always return results in reverse-chronological order, from **most recent** to least recent.
+ - Data in Seq servers doesn't always use OpenTelemetry semantic conventions. When searching or querying, only use property names from the built-ins described above, that appear on search results, or that are returned from the schema tool.
+ - Bare identifiers like `SomeName` are synonymous with `@Properties['SomeName']`. The latter form allows irregular names to be used.
+ - The only escape sequence allowed and required in Seq strings is a doubled single quote - `''` - which evaluates to an embedded literal single quote. Backslash escaping is not recognized.
+ - `@Timestamp`, `@Start`, and `@Elapsed` are internally represented as .NET `DateTime` ticks (100 ns resolution) in order to support consistent timestamp/duration math. Comparing these properties with strings will fail: use duration literals for durations, and the `DateTime` function to convert from ISO-8601 strings.
+ - Although Seq's types resemble those from JavaScript, Seq does not support JavaScript operators and does not use JavaScript's system of comparisons.
+ - The expression `null = null` is `true` in Seq's type system; `null` is just a regular value.
+ - Timestamp bounds with inclusive starts and exclusive ends are the most efficient for Seq to work with.
+ - Regular expression evaluation is extremely expensive, avoid these as much as possible.
+ - Queries without `from stream` or `from series` are scalar (can't project out fields or compute aggregations).
+ - Searches and queries should always constrain results using `@Timestamp`, `@TraceId`, or `@Id`.
+ - `group by time(..)` requires an inclusive lower time bound on `@Timestamp`.
+ - Queries impose a default limit of 1024 rows, which can be changed with the `limit` clause. Set smaller limits to conserve resources when speculatively exploring.
+ - Use `ToIsoString()` and `ToTimeString()` to make timestamps or durations (even computed ones) readable. If you forget, you can convert individual values cheaply with a scalar query like `ToIsoString(12345)`.
+ - When grouping by `time(..)`, the time ordering leaves of the interval - just `order by time`, the interval isn't re-specified.
+ - All function calls and operators are case-sensitive unless the `ci` modifier is appended.
diff --git a/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md b/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
new file mode 100644
index 00000000..9e2a4190
--- /dev/null
+++ b/src/SeqCli/Skills/Resources/working-with-seq-metrics/SKILL.md
@@ -0,0 +1,157 @@
+---
+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
+```
+
+### `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.
+- 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/src/SeqCli/Skills/SkillInstaller.cs b/src/SeqCli/Skills/SkillInstaller.cs
new file mode 100644
index 00000000..eec37480
--- /dev/null
+++ b/src/SeqCli/Skills/SkillInstaller.cs
@@ -0,0 +1,91 @@
+// 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 Serilog;
+
+namespace SeqCli.Skills;
+
+static class SkillInstaller
+{
+    static readonly IReadOnlyDictionary KnownAgents =
+        new Dictionary
+        {
+            ["copilot"] = new(global => global
+                ? Path.Combine(UserProfile, ".copilot", "skills")
+                : Path.Combine(Environment.CurrentDirectory, ".github", "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);
+
+        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);
+
+            Console.Write("Installing skill `{0}` to `{1}`...", skillName, destinationPath);
+
+            CopyFilesRecursive(skillSourceDirectory, destination);
+            
+            Console.WriteLine(" Done.");
+        }
+    }
+
+    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)
+    {
+        Directory.CreateDirectory(destination);
+        
+        foreach (var file in Directory.EnumerateFiles(source))
+        {
+            File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true);
+        }
+
+        foreach (var directory in Directory.EnumerateDirectories(source))
+        {
+            CopyFilesRecursive(directory, Path.Combine(destination, Path.GetFileName(directory)));
+        }
+    }
+
+    sealed record SkillTarget(Func ResolveSkillsDirectory);
+}
\ No newline at end of file
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..0bc3d028 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.
@@ -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/src/SeqCli/Syntax/QueryBuilder.cs b/src/SeqCli/Syntax/QueryBuilder.cs
index b2338ec4..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.
@@ -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())
         {
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..772ca43f 100644
--- a/src/SeqCli/Templates/Export/TemplateSetExporter.cs
+++ b/src/SeqCli/Templates/Export/TemplateSetExporter.cs
@@ -9,9 +9,10 @@
 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.SqlQueries;
+using Seq.Api.Model.Queries;
 using Seq.Api.Model.Workspaces;
 using Serilog;
 
@@ -35,7 +36,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 +51,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);
             
@@ -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(
@@ -97,7 +104,7 @@ async Task ExportTemplates(
         where TEntity : Entity
     {
         List entities;
-        if (!_include.Any())
+        if (_include.Count == 0)
         {
             entities = await listEntities();
         }
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));
             
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/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/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.
diff --git a/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs b/test/SeqCli.EndToEnd/Dashboard/RenderTestCase.cs
index 4c29aff9..c1810319 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(
@@ -17,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);
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);
+    }
+}
diff --git a/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs
new file mode 100644
index 00000000..aa39ab3a
--- /dev/null
+++ b/test/SeqCli.EndToEnd/Mcp/McpInstallTestCase.cs
@@ -0,0 +1,136 @@
+using System.IO;
+using System.Threading.Tasks;
+using Seq.Api;
+using SeqCli.EndToEnd.Support;
+using Serilog;
+using Xunit;
+
+namespace SeqCli.EndToEnd.Mcp;
+
+// ReSharper disable once UnusedType.Global
+public class McpInstallTestCase : ICliTestCase
+{
+    public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner)
+    {
+        using var tmp = new TestDataFolder();
+
+        // Convention fallback: an agent that isn't specially known writes `.{agent}/mcp.json`
+        // with the common `mcpServers` shape pointing at a bare `mcp run`.
+        var exit = runner.Exec("mcp install -a test-agent", disconnected: true, workingDirectory: tmp.Path);
+        Assert.Equal(0, exit);
+
+        var config = File.ReadAllText(Path.Combine(tmp.Path, ".test-agent/mcp.json"));
+        Assert.Contains("\"mcpServers\"", config);
+        Assert.Contains("\"seq\"", config);
+        Assert.Contains("\"seqcli\"", config);
+
+        // Known-agent override: Claude Code reads a root `.mcp.json`, not `.claude/mcp.json`.
+        var claudeExit = runner.Exec("mcp install -a claude", disconnected: true, workingDirectory: tmp.Path);
+        Assert.Equal(0, claudeExit);
+        Assert.True(File.Exists(Path.Combine(tmp.Path, ".mcp.json")));
+        Assert.False(File.Exists(Path.Combine(tmp.Path, ".claude/mcp.json")));
+
+        // Merge preserves any server already present rather than overwriting the file.
+        var mergePath = Path.Combine(tmp.Path, ".merge-agent/mcp.json");
+        Directory.CreateDirectory(Path.GetDirectoryName(mergePath)!);
+        File.WriteAllText(mergePath, "{\"mcpServers\":{\"other\":{\"command\":\"x\"}}}");
+
+        var mergeExit = runner.Exec("mcp install -a merge-agent", disconnected: true, workingDirectory: tmp.Path);
+        Assert.Equal(0, mergeExit);
+
+        var merged = File.ReadAllText(mergePath);
+        Assert.Contains("\"other\"", merged);
+        Assert.Contains("\"seq\"", merged);
+
+        // A `--profile` is propagated onto the generated server's `mcp run` args; other
+        // connection settings (server URL, API key) are deliberately left to runtime config.
+        var profileExit = runner.Exec("mcp install -a profile-agent --profile Production", disconnected: true, workingDirectory: tmp.Path);
+        Assert.Equal(0, profileExit);
+
+        var profileConfig = File.ReadAllText(Path.Combine(tmp.Path, ".profile-agent/mcp.json"));
+        Assert.Contains("\"--profile\"", profileConfig);
+        Assert.Contains("\"Production\"", profileConfig);
+
+        // Qwen Code reads MCP servers from `mcpServers` in its `settings.json`, not an `mcp.json`.
+        var qwenExit = runner.Exec("mcp install -a qwen", disconnected: true, workingDirectory: tmp.Path);
+        Assert.Equal(0, qwenExit);
+
+        var qwenConfig = File.ReadAllText(Path.Combine(tmp.Path, ".qwen/settings.json"));
+        Assert.Contains("\"mcpServers\"", qwenConfig);
+        Assert.Contains("\"seq\"", qwenConfig);
+        Assert.False(File.Exists(Path.Combine(tmp.Path, ".qwen/mcp.json")));
+
+        // VS Code 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/Mcp/McpMetricsBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpMetricsBasicsTestCase.cs
new file mode 100644
index 00000000..77fc5f69
--- /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/McpSessionBasicsTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs
new file mode 100644
index 00000000..5ab7e4ae
--- /dev/null
+++ b/test/SeqCli.EndToEnd/Mcp/McpSessionBasicsTestCase.cs
@@ -0,0 +1,69 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+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 McpSessionBasicsTestCase : McpToolTestCase
+{
+    protected override async Task ExecuteAsync(SeqConnection connection, ILogger logger, McpClient client)
+    {
+        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 predicate = $"RunId = '{runId}' and Customer.Tier = 'gold' and @Timestamp >= Now() - 1d";
+        var searchResult = AssertTextResult(await client.CallToolAsync(
+            "seq_search_events",
+            new Dictionary { ["limit"] = 10, ["predicate"] = predicate }));
+        var resultIds = OrderedSearchResultIds(searchResult);
+        Assert.Equal(orders.Count(o => o.Customer.Tier == "gold"), resultIds.Length);
+
+        var detailResult = AssertTextResult(await client.CallToolAsync(
+            "seq_read_search_result",
+            new Dictionary { ["result_id"] = resultIds[0] }));
+        Assert.Contains(runId, detailResult);
+        Assert.Contains("Name: 'Carol'", detailResult);
+
+        var schemaResult = AssertTextResult(await client.CallToolAsync("seq_inspect_result_schema"));
+        foreach (var expectedPath in new[]
+                 {
+                     "OrderNumber", "RunId", "Amount", "Customer", "Customer.Name", "Customer.Tier",
+                     "Customer.Address.City"
+                 })
+        {
+            Assert.Contains(expectedPath, schemaResult);
+        }
+
+        var query = $"select sum(Amount) as Total from stream where RunId = '{runId}' and @Timestamp >= Now() - 1d";
+        var queryResult = AssertTextResult(await client.CallToolAsync(
+            "seq_query",
+            new Dictionary { ["query"] = query }));
+        Assert.Contains("Total", queryResult);
+        Assert.Contains("6", queryResult);
+
+        await client.CallToolAsync("seq_new_session");
+
+        var staleResult = await client.CallToolAsync(
+            "seq_read_search_result",
+            new Dictionary { ["result_id"] = resultIds[0] });
+        Assert.True(staleResult.IsError ?? false);
+    }
+}
diff --git a/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs
new file mode 100644
index 00000000..91ed9898
--- /dev/null
+++ b/test/SeqCli.EndToEnd/Mcp/McpSignalUsageTestCase.cs
@@ -0,0 +1,75 @@
+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
+{
+    // ReSharper disable once NotAccessedPositionalProperty.Local
+    record SignalSummary(string Id, string Title);
+
+    // Default signals included in every Seq installation.
+    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)
+    {
+        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 signals = AssertStructuredResult(await client.CallToolAsync("seq_list_signals"));
+        foreach (var signal in new[] { Errors, Warnings, Spans, Logs })
+        {
+            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.Id}~{Warnings.Id}"));
+
+        // Intersection: all of the warnings are log events, not spans.
+        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.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.Id}~{Warnings.Id}),{Logs.Id}"));
+    }
+
+    static async Task CountSearchResultsAsync(McpClient client, string predicate, string signal)
+    {
+        var searchResult = AssertTextResult(await client.CallToolAsync(
+            "seq_search_events",
+            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..b91697c7
--- /dev/null
+++ b/test/SeqCli.EndToEnd/Mcp/McpToolTestCase.cs
@@ -0,0 +1,63 @@
+using System.Linq;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+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 .
+/// 
+[UsedImplicitly(ImplicitUseTargetFlags.WithInheritors)]
+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 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 result.Deserialize(JsonSerializerOptions.Web)!;
+    }
+
+    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();
+}
diff --git a/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs
new file mode 100644
index 00000000..4131f1df
--- /dev/null
+++ b/test/SeqCli.EndToEnd/Metrics/MetricsCliBasics.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Seq.Api;
+using SeqCli.EndToEnd.Support;
+using Serilog;
+using Xunit;
+
+#nullable enable
+
+namespace SeqCli.EndToEnd.Metrics;
+
+[CliTestCase(MinimumApiVersion = "2026.1.0")]
+class MetricsCliBasics: ICliTestCase
+{
+    public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRunner runner)
+    {
+        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());
+        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 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));
+    }
+
+    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!;
+            }
+        }
+    }
+}
\ 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/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"
     }
   }
 }
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/SeqCli.EndToEnd.csproj b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
index 797cf88c..0816d85f 100644
--- a/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
+++ b/test/SeqCli.EndToEnd/SeqCli.EndToEnd.csproj
@@ -2,9 +2,12 @@
 
   
     Exe
-    net9.0
+    net10.0
+    false
+    false
   
   
+    
     
   
   
diff --git a/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs
new file mode 100644
index 00000000..88871bc1
--- /dev/null
+++ b/test/SeqCli.EndToEnd/Skills/SkillsInstallTestCase.cs
@@ -0,0 +1,51 @@
+using System.IO;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+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();
+
+        // 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")));
+
+        // 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")));
+
+        // 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 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, ".agents/skills/seq-search-and-query/SKILL.md")));
+
+        return Task.CompletedTask;
+    }
+}
\ No newline at end of file
diff --git a/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs b/test/SeqCli.EndToEnd/Support/CaptiveProcess.cs
index fb11e841..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)
@@ -111,11 +114,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;
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/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
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
+}
diff --git a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs
index e3b35aa8..fff6c7af 100644
--- a/test/SeqCli.EndToEnd/Support/TestConfiguration.cs
+++ b/test/SeqCli.EndToEnd/Support/TestConfiguration.cs
@@ -31,15 +31,15 @@ public TestConfiguration(Args args)
 
     public bool IsMultiuser => _args.Multiuser();
 
-    public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false)
+    public CaptiveProcess SpawnCliProcess(string command, string additionalArgs = null, Dictionary environment = null, bool skipServerArg = false, bool supplyInput = false, string workingDirectory = null)
     {
         if (command == null) throw new ArgumentNullException(nameof(command));
 
         var commandWithArgs = $"{command} {additionalArgs}";
         if (!skipServerArg)
             commandWithArgs += $" --server=\"{ServerListenUrl}\"";
-            
-        return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput);
+
+        return new CaptiveProcess("dotnet", $"{TestedBinary} {commandWithArgs}", environment, supplyInput: supplyInput, workingDirectory: workingDirectory);
     }
         
     public CaptiveProcess SpawnServerProcess(string storagePath)
diff --git a/test/SeqCli.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..18b7d6e7 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(
@@ -20,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/Apps/AppContainerTests.cs b/test/SeqCli.Tests/Apps/AppContainerTests.cs
index e769a801..e9cfdecc 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,60 @@ 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 RemovesZeroTraceAndSpanIds()
+    {
+        var jo = new JObject
+        {
+            ["@tr"] = "00000000000000000000000000000000",
+            ["@sp"] = "0000000000000000",
+            ["@ps"] = "0000000000000000"
+        };
+        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
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
diff --git a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreDirectory.cs
index 00b1762d..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);
@@ -30,15 +30,26 @@ 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)
+    public override InMemoryStoreFile Replace(string destinationPath, string sourcePath)
     {
-        _files[toReplace] = _files[replaceWith];
-        _files.Remove(replaceWith);
+        _files[destinationPath] = _files[sourcePath];
+
+        if (!TryDelete(sourcePath))
+        {
+            throw new InvalidOperationException($"Failed to replace {destinationPath} with {sourcePath}");
+        }
 
-        return _files[toReplace];
+        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 26be3beb..9d44b84f 100644
--- a/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs
+++ b/test/SeqCli.Tests/Forwarder/Filesystem/InMemoryStoreFile.cs
@@ -10,13 +10,16 @@ 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;
         return true;
     }
 
-    public void Append(Span incoming)
+    public override void Append(Span incoming)
     {
         var newContents = new byte[Contents.Length + incoming.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();
     }
 }
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);
+        });
     }
 }
diff --git a/test/SeqCli.Tests/Mcp/McpSessionTests.cs b/test/SeqCli.Tests/Mcp/McpSessionTests.cs
new file mode 100644
index 00000000..44c8dc5f
--- /dev/null
+++ b/test/SeqCli.Tests/Mcp/McpSessionTests.cs
@@ -0,0 +1,165 @@
+#nullable enable
+using System;
+using System.Linq;
+using System.Threading;
+using Newtonsoft.Json.Linq;
+using SeqCli.Mcp;
+using SeqCli.Tests.Support;
+using Xunit;
+
+namespace SeqCli.Tests.Mcp;
+
+public class McpSessionTests
+{
+    [Fact]
+    public void ResultIdsRoundTrip()
+    {
+        const int id = 1245;
+        var formatted = McpSession.FormatResultId(id);
+        Assert.True(McpSession.TryParseResultId(formatted, out var rt));
+        Assert.Equal(id, rt);
+    }
+
+    [Fact]
+    public void ImportingTheSameEventReturnsTheSameId()
+    {
+        var session = new McpSession();
+
+        var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1"));
+        var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1"));
+
+        Assert.Equal(first, second);
+    }
+
+    [Fact]
+    public void ImportingDistinctEventsReturnsDistinctIds()
+    {
+        var session = new McpSession();
+
+        var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1"));
+        var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-2"));
+
+        Assert.NotEqual(first, second);
+    }
+
+    [Fact]
+    public void ImportedEventsCanBeRetrievedById()
+    {
+        var session = new McpSession();
+        var evt = Some.MakeEvent();
+
+        var resultId = session.ImportSearchResult(evt);
+
+        Assert.True(session.TryGetSearchResult(resultId, out var result, out var error));
+        Assert.Same(evt, result);
+        Assert.Null(error);
+    }
+
+    [Fact]
+    public void MalformedResultIdsAreRejected()
+    {
+        var session = new McpSession();
+
+        Assert.False(session.TryGetSearchResult("not-a-result-id", out var result, out var error));
+        Assert.Null(result);
+        Assert.NotNull(error);
+    }
+
+    [Fact]
+    public void WellFormedButUnknownResultIdsReturnAnError()
+    {
+        var session = new McpSession();
+        var unknown = McpSession.FormatResultId(999);
+
+        Assert.False(session.TryGetSearchResult(unknown, out var result, out var error));
+        Assert.Null(result);
+        Assert.NotNull(error);
+    }
+
+    [Fact]
+    public void NoUserPropertyNamesAreEnumeratedWithoutResults()
+    {
+        var session = new McpSession();
+
+        Assert.Empty(session.EnumerateUserPropertyNames(CancellationToken.None));
+    }
+
+    [Fact]
+    public void UserPropertyNamesAreEnumeratedAcrossPropertiesScopeAndResource()
+    {
+        var session = new McpSession();
+        session.ImportSearchResult(Some.MakeEvent(e =>
+        {
+            e.Id = "event-1";
+            e.Properties = Some.MakeProperties(("UserId", 42));
+            e.Scope = Some.MakeProperties(("name", "my-scope"));
+            e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }));
+        }));
+
+        var names = session.EnumerateUserPropertyNames(CancellationToken.None).ToList();
+
+        Assert.Contains("UserId", names);
+        Assert.Contains("@Scope.name", names);
+        Assert.Contains("@Resource.service", names);
+        Assert.Contains("@Resource.service.name", names);
+    }
+
+    [Fact]
+    public void UserPropertyNamesAreDeduplicatedAcrossResults()
+    {
+        var session = new McpSession();
+        session.ImportSearchResult(Some.MakeEvent(e =>
+        {
+            e.Id = "event-1";
+            e.Properties = Some.MakeProperties(("UserId", 1));
+        }));
+        session.ImportSearchResult(Some.MakeEvent(e =>
+        {
+            e.Id = "event-2";
+            e.Properties = Some.MakeProperties(("UserId", 2));
+        }));
+
+        var names = session.EnumerateUserPropertyNames(CancellationToken.None).ToList();
+
+        Assert.Equal(["UserId"], names);
+    }
+
+    [Fact]
+    public void EnumeratingUserPropertyNamesObservesCancellation()
+    {
+        var session = new McpSession();
+        session.ImportSearchResult(Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42))));
+
+        using var cts = new CancellationTokenSource();
+        cts.Cancel();
+
+        Assert.Throws(
+            () => session.EnumerateUserPropertyNames(cts.Token).ToList());
+    }
+
+    [Fact]
+    public void ClearForgetsImportedResults()
+    {
+        var session = new McpSession();
+        var resultId = session.ImportSearchResult(Some.MakeEvent());
+
+        session.Clear();
+
+        Assert.False(session.TryGetSearchResult(resultId, out var result, out var error));
+        Assert.Null(result);
+        Assert.NotNull(error);
+        Assert.Empty(session.EnumerateUserPropertyNames(CancellationToken.None));
+    }
+
+    [Fact]
+    public void ClearPreservesTheResultIdSequence()
+    {
+        var session = new McpSession();
+        var first = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1"));
+
+        session.Clear();
+
+        var second = session.ImportSearchResult(Some.MakeEvent(e => e.Id = "event-1"));
+        Assert.NotEqual(first, second);
+    }
+}
diff --git a/test/SeqCli.Tests/Output/NativeFormatterTests.cs b/test/SeqCli.Tests/Output/NativeFormatterTests.cs
new file mode 100644
index 00000000..82251913
--- /dev/null
+++ b/test/SeqCli.Tests/Output/NativeFormatterTests.cs
@@ -0,0 +1,149 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Newtonsoft.Json.Linq;
+using Seq.Api.Model.Events;
+using SeqCli.Tests.Support;
+using Xunit;
+using NativeFormatter = SeqCli.Output.NativeFormatter;
+
+namespace SeqCli.Tests.Output;
+
+public class NativeFormatterTests
+{
+    [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 = NativeFormatter.MakeIdentifier(prefix, name, prefixIsOptional);
+        Assert.Equal(expected, actual);
+    }
+
+    [Theory]
+    [MemberData(nameof(EventPropertyCases))]
+    public void EventPropertiesAreFormatted(EventEntity evt, string expectedLiteral)
+    {
+        Assert.Contains(expectedLiteral, Render(evt));
+    }
+
+    public static IEnumerable EventPropertyCases() =>
+    [
+        [Some.MakeEvent(e => e.Id = "abc"), "@Id: 'abc'"],
+        [Some.MakeEvent(e => e.Timestamp = "2024-01-01T00:00:00.0000000Z"), "@Timestamp: DateTime('2024-01-01T00:00:00.0000000Z')"],
+        [Some.MakeEvent(e => e.Level = "Error"), "@Level: 'Error'"],
+        [Some.MakeEvent(e => e.RenderedMessage = "hello world"), "@Message: 'hello world'"],
+        [Some.MakeEvent(e => e.RenderedMessage = "it's"), "@Message: 'it''s'"],
+        [
+            Some.MakeEvent(e => e.MessageTemplateTokens =
+            [new MessageTemplateTokenPart { Text = "User " }, new MessageTemplateTokenPart { RawText = "{UserId}", PropertyName = "UserId" }]),
+            "@MessageTemplate: 'User {UserId}'"
+        ],
+        [Some.MakeEvent(e => e.EventType = "$0000000a"), "@EventType: 10"],
+        [Some.MakeEvent(e => e.EventType = "$c0ffee00"), "@EventType: 3237998080"],
+        [Some.MakeEvent(e => e.EventType = "$00000000"), "@EventType: 0"],
+        [Some.MakeEvent(e => e.Exception = "System.Exception: boom"), "@Exception: 'System.Exception: boom'"],
+        [Some.MakeEvent(e => e.Elapsed = TimeSpan.FromSeconds(13)), "@Elapsed: 13s"],
+        [Some.MakeEvent(e => e.TraceId = "abc123"), "@TraceId: 'abc123'"],
+        [Some.MakeEvent(e => e.SpanId = "def456"), "@SpanId: 'def456'"],
+        [Some.MakeEvent(e => e.SpanKind = "server"), "@SpanKind: 'server'"],
+        [Some.MakeEvent(e => e.Start = "2024-01-01T00:00:00.0000000Z"), "@Start: DateTime('2024-01-01T00:00:00.0000000Z')"],
+        [Some.MakeEvent(e => e.ParentId = "p1"), "@ParentId: 'p1'"],
+        [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("UserId", 42))), "@Properties: {UserId: 42}"],
+        [Some.MakeEvent(e => e.Scope = Some.MakeProperties(("name", "myscope"))), "@Scope: {name: 'myscope'}"],
+        [Some.MakeEvent(e => e.Resource = Some.MakeProperties(("host", "h"))), "@Resource: {host: 'h'}"],
+        [Some.MakeEvent(e => e.Definitions = Some.MakeProperties(("d", 1))), "@Definitions: {d: 1}"],
+        [Some.MakeEvent(e => e.Level = null), "@Level: 'Information'"],
+        [Some.MakeEvent(e => e.Timestamp = "2024-01-01T12:00:00+02:00"), "@Timestamp: DateTime('2024-01-01T10:00:00.0000000Z')"
+        ],
+        [Some.MakeEvent(e => e.MessageTemplateTokens = [new MessageTemplateTokenPart { PropertyName = "X" }]), "@MessageTemplate: '{X}'"
+        ],
+        [Some.MakeEvent(e => e.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'}"],
+        [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("a", 1), ("b", true))), "@Properties: {a: 1, b: true}"],
+        [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("b", true))), "@Properties: {b: true}"],
+        [Some.MakeEvent(e => e.Properties = Some.MakeProperties(("z", null))), "@Properties: {z: null}"],
+        [
+            Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web" }))),
+            "@Resource: {service: {name: 'web'}}"
+        ],
+        [
+            Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["name"] = "web", ["version"] = "1.0" }))),
+            "@Resource: {service: {name: 'web', version: '1.0'}}"
+        ],
+        [
+            Some.MakeEvent(e => e.Resource = Some.MakeProperties(("service", new JObject { ["namespace"] = new JObject { ["name"] = "web" } }))),
+            "@Resource: {service: {namespace: {name: 'web'}}}"
+        ],
+        [
+            Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["request"] = new JObject { ["method"] = "GET" } }))),
+            "@Properties: {http: {request: {method: 'GET'}}}"
+        ],
+        [
+            Some.MakeEvent(e => e.Scope = Some.MakeProperties(("db", new JObject { ["system"] = "postgres" }))),
+            "@Scope: {db: {system: 'postgres'}}"
+        ],
+        [
+            Some.MakeEvent(e => e.Properties = Some.MakeProperties(("http", new JObject { ["content-type"] = "json" }))),
+            "@Properties: {http: {'content-type': 'json'}}"
+        ],
+        [
+            Some.MakeEvent(e => e.Properties = Some.MakeProperties(("tags", new JArray("a", "b")))),
+            "@Properties: {tags: ['a', 'b']}"
+        ]
+    ];
+
+    [Theory]
+    [InlineData("@Exception")]
+    [InlineData("@Elapsed")]
+    [InlineData("@TraceId")]
+    [InlineData("@SpanId")]
+    [InlineData("@SpanKind")]
+    [InlineData("@Start")]
+    [InlineData("@ParentId")]
+    [InlineData("@Properties")]
+    [InlineData("@Scope")]
+    [InlineData("@Resource")]
+    [InlineData("@Definitions")]
+    public void OptionalPropertiesAreOmittedWhenAbsent(string token)
+    {
+        Assert.DoesNotContain(token, Render(Some.MakeEvent()));
+    }
+
+    [Fact]
+    public void EmptyPropertyCollectionIsOmitted()
+    {
+        Assert.DoesNotContain("@Properties", Render(Some.MakeEvent(e => e.Properties = [])));
+    }
+
+    [Fact]
+    public void EventFormatIsAnObjectLiteral()
+    {
+        Assert.Equal(
+            "{@Id: 'event-1', @Timestamp: DateTime('2024-01-02T00:00:00.0000002Z'), " +
+            "@Level: 'Information', @Message: 'Hello!', @MessageTemplate: 'Hello!', @EventType: 1}",
+            Render(Some.MakeEvent(e =>
+            {
+                e.Id = "event-1";
+                e.Timestamp = "2024-01-02T00:00:00.0000002Z";
+                e.RenderedMessage = "Hello!";
+                e.MessageTemplateTokens = [new MessageTemplateTokenPart { Text = "Hello!" }];
+                e.EventType = "$00000001";
+            })));
+    }
+    
+    static string Render(EventEntity evt)
+    {
+        var output = new StringWriter();
+        NativeFormatter.WriteEvent(output, evt);
+        return output.ToString();
+    }
+}
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;
diff --git a/test/SeqCli.Tests/SeqCli.Tests.csproj b/test/SeqCli.Tests/SeqCli.Tests.csproj
index b15422c9..6b663585 100644
--- a/test/SeqCli.Tests/SeqCli.Tests.csproj
+++ b/test/SeqCli.Tests/SeqCli.Tests.csproj
@@ -1,14 +1,19 @@
 
   
-    net9.0
+    net10.0
   
   
-    
+    
     
     
       all
       runtime; build; native; contentfiles; analyzers; buildtransitive
     
+    
+    
+    
+    
   
   
     
@@ -30,4 +35,4 @@
       PreserveNewest
     
   
-
\ No newline at end of file
+
diff --git a/test/SeqCli.Tests/Skills/SkillComplexityTests.cs b/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
new file mode 100644
index 00000000..11d0604c
--- /dev/null
+++ b/test/SeqCli.Tests/Skills/SkillComplexityTests.cs
@@ -0,0 +1,40 @@
+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 allowedMaxLines = recommendedMaxLines + 100,
+            allowedMaxTokens = recommendedMaxTokens + 4000;
+        
+        var lines = File.ReadAllLines(path);
+        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);
+        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
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