diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f642330..dea0c78e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: DOTNET_CLI_TELEMETRY_OPTOUT: true DOCKER_USER: ${{ secrets.DOCKER_USER }} DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }} + SEQ_DOCKER_TAG: 2026.1 shell: pwsh run: | - ./build/Build.Linux.ps1 + ./build/Build.Linux.ps1 -SeqDockerTag $env:SEQ_DOCKER_TAG diff --git a/README.md b/README.md index 8bd1eb69..906e2f52 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,13 @@ seqcli help apikey Available commands: + - `alert` + - [`alert create`](#alert-create) — Create an alert. + - [`alert disable`](#alert-disable) — Disable one or more alerts. + - [`alert enable`](#alert-enable) — Enable one or more alerts. + - [`alert list`](#alert-list) — List alerts. + - [`alert remove`](#alert-remove) — Remove an alert from the server. + - [`alert update`](#alert-update) — Update an existing alert. - `apikey` - [`apikey create`](#apikey-create) — Create an API key for automation or ingestion. - [`apikey list`](#apikey-list) — List available API keys. @@ -221,6 +228,143 @@ Available commands: - [`workspace remove`](#workspace-remove) — Remove a workspace from the server. - [`workspace update`](#workspace-update) — Update an existing workspace. +### `alert create` + +Create an alert. + +Example: + +``` +seqcli alert create -t 'Too many errors' --select "count(*) as errors" --from stream --signal signal-m33302 --where "@Level = 'Error'" --window 5m --having "errors > 10" --notification-level Error --suppression-time 10m +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | A title for the alert | +| `--description=VALUE` | A description for the alert | +| `--from=VALUE` | The data source the alert queries; either `stream` (the default) or `series` | +| `--signal=VALUE` | A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2` | +| `--lateral=VALUE` | A lateral join over a set-valued function applied to the data source, in the form ` as `, for example `unnest(http.server.request.duration.buckets) as bucket`; this argument can be used multiple times | +| `--where=VALUE` | A predicate that selects the events the alert will consider | +| `--select=VALUE` | A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times | +| `--group-by=VALUE` | An expression to group measurements by, for example `ServiceName` or `ServiceName ci as service`; the `ci` modifier makes the grouping case-insensitive; this argument can be used multiple times | +| `--window=VALUE` | The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h` | +| `--having=VALUE` | The alert condition; a predicate over the grouped measurements, for example `errors > 10` | +| `--notification-level=VALUE` | The level of the notifications raised by the alert, for example `Warning` or `Error` | +| `--suppression-time=VALUE` | A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h` | +| `--notification-app=VALUE` | The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times | +| `--protected` | Specify that the alert is editable only by administrators | +| `--disabled` | Create the alert in a disabled state; disabled alerts are not processed and do not send notifications | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert disable` + +Disable one or more alerts. + +Example: + +``` +seqcli alert disable -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to disable | +| `-i`, `--id=VALUE` | The id of a single alert to disable | +| `-o`, `--owner=VALUE` | The id of the user to disable alerts for; by default, shared alerts are disabled | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert enable` + +Enable one or more alerts. + +Example: + +``` +seqcli alert enable -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to enable | +| `-i`, `--id=VALUE` | The id of a single alert to enable | +| `-o`, `--owner=VALUE` | The id of the user to enable alerts for; by default, shared alerts are enabled | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert list` + +List alerts. + +Example: + +``` +seqcli alert list +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to list | +| `-i`, `--id=VALUE` | The id of a single alert to list | +| `-o`, `--owner=VALUE` | The id of the user to list alerts for; by default, shared alerts are listed | +| `--json` | Print output in newline-delimited JSON (the default is plain text) | +| `--no-color` | Don't colorize text output | +| `--force-color` | Force redirected output to have ANSI color (unless `--no-color` is also specified) | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | + +### `alert remove` + +Remove an alert from the server. + +Example: + +``` +seqcli alert remove -t 'Too many errors' +``` + +| Option | Description | +| ------ | ----------- | +| `-t`, `--title=VALUE` | The title of the alert(s) to remove | +| `-i`, `--id=VALUE` | The id of a single alert to remove | +| `-o`, `--owner=VALUE` | The id of the user to remove alerts for; by default, shared alerts are removed | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + +### `alert update` + +Update an existing alert. + +Example: + +``` +seqcli alert update --json '{...}' +``` + +| Option | Description | +| ------ | ----------- | +| `--json=VALUE` | The updated alert in JSON format; this can be produced using `seqcli alert list --json` | +| `--json-stdin` | Read the updated alert as JSON from `STDIN` | +| `-s`, `--server=VALUE` | The URL of the Seq server; by default the `connection.serverUrl` config value will be used | +| `-a`, `--apikey=VALUE` | The API key to use when connecting to the server; by default the `connection.apiKey` config value will be used | +| `--profile=VALUE` | A connection profile to use; by default the `connection.serverUrl` and `connection.apiKey` config values will be used | +| `--storage=VALUE` | The folder where `SeqCli.json` and other data will be stored; falls back to `SEQCLI_STORAGE_PATH` from the environment, then the `seqcli forwarder` service's configured storage path (Windows only), then the current user's home directory | + ### `apikey create` Create an API key for automation or ingestion. diff --git a/build/Build.Linux.ps1 b/build/Build.Linux.ps1 index 1d3279b8..cc550b3d 100644 --- a/build/Build.Linux.ps1 +++ b/build/Build.Linux.ps1 @@ -1,3 +1,7 @@ +param( + [string] $SeqDockerTag = "latest" +) + Push-Location $PSScriptRoot/../ . ./build/Build.Common.ps1 @@ -23,8 +27,8 @@ function Execute-Tests if ($LASTEXITCODE -ne 0) { exit 1 } cd ./test/SeqCli.EndToEnd/ - docker pull datalust/seq:latest - & dotnet run -f $framework -- --docker-server + docker pull datalust/seq:$SeqDockerTag + & dotnet run -f $framework -- --docker-server --docker-tag=$SeqDockerTag if ($LASTEXITCODE -ne 0) { cd ../.. diff --git a/src/Roastery/Agents/Agent.cs b/src/Roastery/Agents/Agent.cs index 5e24ea79..2c50fbb7 100644 --- a/src/Roastery/Agents/Agent.cs +++ b/src/Roastery/Agents/Agent.cs @@ -9,14 +9,14 @@ namespace Roastery.Agents; abstract class Agent { protected delegate Task Behavior(CancellationToken cancellationToken); - + readonly int _meanBehaviorIntervalMilliseconds; protected Agent(int meanBehaviorIntervalMilliseconds) { _meanBehaviorIntervalMilliseconds = meanBehaviorIntervalMilliseconds; } - + public static Task Run(Agent agent, CancellationToken cancellationToken) { return Task.Run(() => agent.RunBehaviorsAsync(cancellationToken), cancellationToken); @@ -32,8 +32,8 @@ async Task RunBehaviorsAsync(CancellationToken cancellationToken) { var behavior = Distribution.Uniform(behaviors); - await Task.Delay((int) Distribution.Uniform(0, _meanBehaviorIntervalMilliseconds), cancellationToken); - + await Task.Delay((int)Distribution.Uniform(0, _meanBehaviorIntervalMilliseconds), cancellationToken); + try { await behavior(cancellationToken); @@ -42,10 +42,9 @@ async Task RunBehaviorsAsync(CancellationToken cancellationToken) { // Exceptions are swallowed here; agents can log exceptions if they wish } - - await Task.Delay(_meanBehaviorIntervalMilliseconds / 2 + - (int) (_meanBehaviorIntervalMilliseconds * Distribution.Uniform()), cancellationToken); + await Task.Delay(_meanBehaviorIntervalMilliseconds / 2 + + (int)(_meanBehaviorIntervalMilliseconds * Distribution.Uniform()), cancellationToken); } } diff --git a/src/Roastery/Agents/ArchivingBatch.cs b/src/Roastery/Agents/ArchivingBatch.cs index 0e1518ee..d1cbf489 100644 --- a/src/Roastery/Agents/ArchivingBatch.cs +++ b/src/Roastery/Agents/ArchivingBatch.cs @@ -14,7 +14,7 @@ class ArchivingBatch : Agent readonly HttpClient _client; readonly ILogger _logger; - public ArchivingBatch(HttpClient client, ILogger logger) + public ArchivingBatch(HttpClient client, ILogger logger) : base(30000) { _client = client; @@ -33,7 +33,7 @@ async Task DeleteAbandonedCarts(CancellationToken cancellationToken) try { _logger.Information("Identifying abandoned orders"); - + var orders = await _client.GetAsync>("api/orders"); foreach (var order in orders) { @@ -60,7 +60,7 @@ async Task ArchiveShippedOrders(CancellationToken cancellationToken) try { _logger.Information("Archiving completed orders"); - + var orders = await _client.GetAsync>("api/orders"); foreach (var order in orders) { diff --git a/src/Roastery/Agents/CatalogBatch.cs b/src/Roastery/Agents/CatalogBatch.cs index 30bfa60f..e0eb4b48 100644 --- a/src/Roastery/Agents/CatalogBatch.cs +++ b/src/Roastery/Agents/CatalogBatch.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Roastery.Model; -using Roastery.Util; using Roastery.Web; using Serilog; using Serilog.Context; @@ -27,21 +27,28 @@ protected override IEnumerable GetBehaviors() yield return CheckStock; } + const double LowStockThresholdKilograms = 40; + async Task CheckStock(CancellationToken cancellationToken) { using var _ = LogContext.PushProperty("BatchId", Guid.NewGuid()); try { _logger.Information("Checking stock levels"); - + + var inventory = await _httpClient.GetAsync>("api/inventory"); + var stockByBlend = inventory.ToDictionary(i => i.Blend, i => i.QuantityKilograms); + foreach (var product in await _httpClient.GetAsync>("api/products")) { - _logger.Information("Checking product {ProductDescription} ({ProductId})", product.FormatDescription(), product.Id); - - if (Distribution.OnceIn(30)) - _logger.Warning("Product {ProductId} is low on stock", product.Id); - else if (Distribution.OnceIn(70)) + _logger.Information("Checking product {ProductDescription} ({ProductId})", product.FormatDescription(), + product.Id); + + var stock = stockByBlend.GetValueOrDefault(product.Blend); + if (stock < product.SizeInGrams / 1000.0) _logger.Warning("Product {ProductId} is out of stock", product.Id); + else if (stock < LowStockThresholdKilograms) + _logger.Warning("Product {ProductId} is low on stock", product.Id); } } catch (Exception ex) diff --git a/src/Roastery/Agents/Customer.cs b/src/Roastery/Agents/Customer.cs index 79f5bef6..0c7968b1 100644 --- a/src/Roastery/Agents/Customer.cs +++ b/src/Roastery/Agents/Customer.cs @@ -39,18 +39,18 @@ async Task CreateOrder(CancellationToken cancellationToken) var orderPath = $"api/orders/{order.Id}"; var addItemPath = $"{orderPath}/items"; var products = await _httpClient.GetAsync>("api/products"); - var items = (int) Distribution.Uniform(1, 5); + var items = (int)Distribution.Uniform(1, 5); for (var i = 0; i < items; ++i) { await Task.Delay((int)Distribution.Uniform(5000, 20000), cancellationToken); - + var product = Distribution.Uniform(products); await _httpClient.PostAsync(addItemPath, new OrderItem(order.Id!, product.Id!)); } if (Distribution.OnceIn(15)) return; // Abandon cart :-) - + // Customer has ~90s to place order before it'll be cleaned up as abandoned; some will be too slow await Task.Delay((int)Distribution.Uniform(10000, 70000), cancellationToken); diff --git a/src/Roastery/Agents/FacilitySensors.cs b/src/Roastery/Agents/FacilitySensors.cs new file mode 100644 index 00000000..e3c1bd80 --- /dev/null +++ b/src/Roastery/Agents/FacilitySensors.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Roastery.Metrics; +using Roastery.Util; +using Serilog; + +namespace Roastery.Agents; + +class FacilitySensors : Agent +{ + class AreaState + { + public AreaState(string area, double baseTemperature, double relativeHumidity) + { + Area = area; + BaseTemperature = baseTemperature; + RelativeHumidity = relativeHumidity; + } + + public string Area { get; } + public double BaseTemperature { get; } + public double RelativeHumidity { get; set; } + } + + readonly ILogger _logger; + readonly RoasteryProductionMetrics _metrics; + + readonly AreaState[] _areas = + [ + new("Roasting Floor", baseTemperature: 27, relativeHumidity: 52), + new("Green Bean Warehouse", baseTemperature: 19, relativeHumidity: 60) + ]; + + double _barometricPressure = 1015; + + public FacilitySensors(ILogger logger, RoasteryProductionMetrics metrics) + : base(6000) + { + _logger = logger.ForContext(); + _metrics = metrics; + } + + protected override IEnumerable GetBehaviors() + { + yield return SampleEnvironment; + } + + Task SampleEnvironment(CancellationToken cancellationToken) + { + // Facility temperatures peak mid-afternoon and bottom out overnight + var hour = DateTime.Now.TimeOfDay.TotalHours; + var diurnalSwing = 3.5 * Math.Sin((hour - 9.0) / 24.0 * 2.0 * Math.PI); + + _barometricPressure = Math.Clamp(_barometricPressure + Distribution.Uniform(0, 0.6) - 0.3, 990, 1035); + + foreach (var area in _areas) + { + var temperature = area.BaseTemperature + diurnalSwing + Distribution.Uniform(0, 0.8) - 0.4; + area.RelativeHumidity = Math.Clamp( + area.RelativeHumidity + (58 - area.RelativeHumidity) * 0.02 + Distribution.Uniform(0, 2.4) - 1.2, 35, + 85); + + _metrics.RecordAmbientConditions( + new RoasteryProductionMetrics.Sample.AmbientKey(area.Area), + new RoasteryProductionMetrics.Sample.AmbientGauges( + Math.Round(temperature, 1), + Math.Round(area.RelativeHumidity, 1), + Math.Round(_barometricPressure, 1))); + + if (area.RelativeHumidity > 75 && Distribution.OnceIn(10)) + _logger.Warning( + "Relative humidity in the {Area} has reached {RelativeHumidity:F0}%; green coffee should be stored below 65% RH", + area.Area, area.RelativeHumidity); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Roastery/Agents/RoastingMachine.cs b/src/Roastery/Agents/RoastingMachine.cs new file mode 100644 index 00000000..990d0775 --- /dev/null +++ b/src/Roastery/Agents/RoastingMachine.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Roastery.Metrics; +using Roastery.Model; +using Roastery.Util; +using Serilog; +using Serilog.Context; +using Serilog.Events; +using SerilogTracing; + +namespace Roastery.Agents; + +class RoastingMachine : Agent +{ + static readonly RoastProfile[] Profiles = + [ + new("1 AM Medium Roast", DropTemperatureCelsius: 210, FinalBurnerLevelPercent: 50, + TypicalWeightLossPercent: 13.5), + new("Rocket Ship Dark Roast", DropTemperatureCelsius: 224, FinalBurnerLevelPercent: 58, + TypicalWeightLossPercent: 16.5) + ]; + + // The bean probe reading rises toward the drum environment temperature at a rate + // proportional to the difference between them; the operator steps the burner down + // as the roast approaches its target, producing the characteristic declining + // rate-of-rise curve. + const double TurningPointCelsius = 95; + const double InitialBurnerLevelPercent = 90; + const double HeatTransferPerMinute = 0.2; + const int TickMilliseconds = 4000; + + class RoastState + { + public double BeanTemperature; + public double RateOfRise; + public double BurnerLevel = InitialBurnerLevelPercent; + public double DrumSpeed = 64; + public bool PassedTurningPoint; + public double? FaultAtTemperature; + public int FaultTicksRemaining; + public bool HadFault; + } + + readonly ILogger _logger; + readonly RoasteryProductionMetrics _metrics; + readonly LoadingDock _loadingDock; + readonly ProductionSchedule _productionSchedule; + readonly MaintenanceSchedule _maintenanceSchedule; + readonly string _machineId; + bool _offlineForServicing; + + // Each machine's temperature calibration drifts a little; this shows up as a + // per-machine skew in roast durations and weight loss + readonly double _calibrationBiasCelsius = Distribution.Uniform(0, 6) - 3; + + public RoastingMachine(ILogger logger, RoasteryProductionMetrics metrics, LoadingDock loadingDock, + ProductionSchedule productionSchedule, MaintenanceSchedule maintenanceSchedule, string machineId) + : base(20000) + { + _logger = logger.ForContext(); + _metrics = metrics; + _loadingDock = loadingDock; + _productionSchedule = productionSchedule; + _maintenanceSchedule = maintenanceSchedule; + _machineId = machineId; + } + + protected override IEnumerable GetBehaviors() + { + yield return RoastBatch; + } + + async Task RoastBatch(CancellationToken cancellationToken) + { + // Check whether the machine is offline for maintenance + // This forces stock to deplete and triggers a cascade of failure events + if (_maintenanceSchedule.IsUnderMaintenance()) + { + if (!_offlineForServicing) + { + _offlineForServicing = true; + _logger.Warning( + "Roasting machine {MachineId} is offline: the afterburner exhaust system requires servicing; roasting is suspended", + _machineId); + } + + return; + } + + if (_offlineForServicing) + { + _offlineForServicing = false; + _logger.Information("Servicing complete; roasting machine {MachineId} is back online", _machineId); + } + + // The machine sits idle until the warehouse requests more stock of a blend + var requestedBlend = _productionSchedule.TakeRequest(); + if (requestedBlend == null) + return; + + var profile = Profiles.FirstOrDefault(p => p.Name == requestedBlend); + if (profile == null) + return; + + var roastId = "roast-" + Guid.NewGuid().ToString("n")[..8]; + var key = new RoasteryProductionMetrics.Sample.RoastKey(_machineId, roastId, profile.Name); + + using var _ = LogContext.PushProperty("MachineId", _machineId); + using var __ = LogContext.PushProperty("RoastId", roastId); + using var activity = _logger.StartActivity("Roast {RoastProfile} batch {RoastId} on machine {MachineId}", + profile.Name, roastId, _machineId); + + var greenWeightKilograms = Math.Round(Distribution.Uniform(80, 110), 1); + var firstCrackTemperature = 194 + Distribution.Uniform(0, 4); + + var state = new RoastState + { + BeanTemperature = 190 + Distribution.Uniform(0, 6) - 3 + _calibrationBiasCelsius, + FaultAtTemperature = Distribution.OnceIn(8) ? Distribution.Uniform(120, 190) : null + }; + + _metrics.RecordRoastBatchStarted(key); + _logger.Information( + "Charged {GreenWeightKilograms}kg of green beans for {RoastProfile} at drum temperature {ChargeTemperature:F1}°C", + greenWeightKilograms, profile.Name, state.BeanTemperature); + + var roastTiming = Stopwatch.StartNew(); + + await AdvancePhaseAsync("Drying", key, profile, state, 150, cancellationToken); + await AdvancePhaseAsync("Browning", key, profile, state, firstCrackTemperature, cancellationToken); + + _logger.Information("First crack detected at {BeanTemperature:F1}°C, {ElapsedSeconds:F0}s into the roast", + state.BeanTemperature, roastTiming.Elapsed.TotalSeconds); + + await AdvancePhaseAsync("Development", key, profile, state, profile.DropTemperatureCelsius, cancellationToken); + + roastTiming.Stop(); + var dropTemperature = state.BeanTemperature; + + using (_logger.StartActivity("Cooling batch {RoastId}", roastId)) + { + state.BurnerLevel = 0; + state.RateOfRise = 0; + for (var i = 0; i < 4; ++i) + { + await Task.Delay(TickMilliseconds, cancellationToken); + state.BeanTemperature += (45 - state.BeanTemperature) * 0.4; + RecordTelemetry(key, state); + } + } + + var durationSeconds = Math.Round(roastTiming.Elapsed.TotalSeconds, 1); + activity.AddProperty("RoastDurationSeconds", durationSeconds); + + if (state.HadFault && Distribution.OnceIn(3) || Distribution.OnceIn(70)) + { + _metrics.RecordRoastBatchRejected(key); + _logger.Error( + "Batch {RoastId} rejected by quality control: uneven development following an unstable roast curve", + roastId); + activity.Complete(LogEventLevel.Error); + return; + } + + var weightLossPercent = Math.Round(profile.TypicalWeightLossPercent + Distribution.Uniform(0, 2.5) - 1.25, 1); + var roastedWeightKilograms = Math.Round(greenWeightKilograms * (1 - weightLossPercent / 100), 1); + + _metrics.RecordRoastBatchCompleted(key, durationSeconds, weightLossPercent); + _loadingDock.Deliver(profile.Name, roastedWeightKilograms); + _logger.Information( + "Dropped {GreenWeightKilograms}kg batch of {RoastProfile} at {DropTemperature:F1}°C after {RoastDurationSeconds}s with {WeightLossPercent}% weight loss; {RoastedWeightKilograms}kg sent to the loading dock", + greenWeightKilograms, profile.Name, dropTemperature, durationSeconds, weightLossPercent, + roastedWeightKilograms); + } + + async Task AdvancePhaseAsync( + string phaseName, + RoasteryProductionMetrics.Sample.RoastKey key, + RoastProfile profile, + RoastState state, + double targetTemperature, + CancellationToken cancellationToken) + { + using var phase = _logger.StartActivity("{RoastPhase} phase of batch {RoastId}", phaseName, key.RoastId); + + while (state.BeanTemperature < targetTemperature || !state.PassedTurningPoint) + { + await Task.Delay((int)Distribution.Uniform(TickMilliseconds - 500, TickMilliseconds + 500), + cancellationToken); + + if (state is { PassedTurningPoint: true, FaultAtTemperature: not null } && + state.BeanTemperature >= state.FaultAtTemperature) + { + state.FaultAtTemperature = null; + state.FaultTicksRemaining = (int)Distribution.Uniform(4, 8); + state.HadFault = true; + _logger.Warning( + "Burner flame-out detected on {MachineId} during {RoastPhase}; rate of rise is crashing", + _machineId, phaseName); + } + + var wasFaulted = state.FaultTicksRemaining > 0; + Advance(state, profile); + if (wasFaulted && state.FaultTicksRemaining == 0) + _logger.Information("Burner reignited on {MachineId}; roast curve recovering", _machineId); + + RecordTelemetry(key, state); + } + + phase.AddProperty("BeanTemperature", Math.Round(state.BeanTemperature, 1)); + } + + void Advance(RoastState state, RoastProfile profile) + { + if (!state.PassedTurningPoint) + { + // The probe reading falls rapidly toward the turning point as the cold beans + // absorb the drum's stored heat + state.RateOfRise = -2.2 * (state.BeanTemperature - 88) + Distribution.Uniform(0, 8) - 4; + if (state.BeanTemperature <= TurningPointCelsius + 2) + { + state.PassedTurningPoint = true; + state.RateOfRise = 2; + } + } + else + { + var progress = Math.Clamp( + (state.BeanTemperature - TurningPointCelsius) / (profile.DropTemperatureCelsius - TurningPointCelsius), + 0, 1); + var targetBurnerLevel = state.FaultTicksRemaining > 0 + ? 15 + : InitialBurnerLevelPercent - (InitialBurnerLevelPercent - profile.FinalBurnerLevelPercent) * progress; + + state.BurnerLevel = Math.Clamp( + state.BurnerLevel + (targetBurnerLevel - state.BurnerLevel) * 0.5 + Distribution.Uniform(0, 4) - 2, 10, + 100); + + var drumEnvironmentTemperature = 175 + state.BurnerLevel * 1.45 + _calibrationBiasCelsius; + state.RateOfRise = Math.Max( + HeatTransferPerMinute * (drumEnvironmentTemperature - state.BeanTemperature) + + Distribution.Uniform(0, 1.5) - 0.75, + 0.3); + + if (state.FaultTicksRemaining > 0) + state.FaultTicksRemaining -= 1; + } + + state.BeanTemperature += state.RateOfRise * TickMilliseconds / 60000.0; + state.DrumSpeed = 64 + Distribution.Uniform(0, 2) - 1; + } + + void RecordTelemetry(RoasteryProductionMetrics.Sample.RoastKey key, RoastState state) + { + _metrics.RecordRoastTelemetry(key, new RoasteryProductionMetrics.Sample.RoastTelemetryGauges( + Math.Round(state.BeanTemperature, 1), + Math.Round(state.RateOfRise, 1), + Math.Round(state.BurnerLevel, 1), + Math.Round(state.DrumSpeed, 1))); + } +} \ No newline at end of file diff --git a/src/Roastery/Agents/WarehouseStaff.cs b/src/Roastery/Agents/WarehouseStaff.cs index b8d02c3c..3ea3bd3f 100644 --- a/src/Roastery/Agents/WarehouseStaff.cs +++ b/src/Roastery/Agents/WarehouseStaff.cs @@ -1,4 +1,6 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Roastery.Model; @@ -8,17 +10,24 @@ namespace Roastery.Agents; class WarehouseStaff : Agent { + const double ReorderThresholdKilograms = 300; + readonly HttpClient _client; + readonly LoadingDock _loadingDock; + readonly ProductionSchedule _productionSchedule; - public WarehouseStaff(HttpClient client) - : base(20000) + public WarehouseStaff(HttpClient client, LoadingDock loadingDock, ProductionSchedule productionSchedule) + : base(12000) { _client = client; + _loadingDock = loadingDock; + _productionSchedule = productionSchedule; } protected override IEnumerable GetBehaviors() { yield return ShipOrders; + yield return RestockShelves; } async Task ShipOrders(CancellationToken cancellationToken) @@ -33,4 +42,35 @@ async Task ShipOrders(CancellationToken cancellationToken) } } } + + async Task RestockShelves(CancellationToken cancellationToken) + { + var inventory = await _client.GetAsync>("api/inventory"); + + foreach (var item in inventory) + { + if (item.QuantityKilograms < ReorderThresholdKilograms) + _productionSchedule.Request(item.Blend); + } + + foreach (var (blend, kilograms) in _loadingDock.Collect()) + { + var item = inventory.FirstOrDefault(i => i.Blend == blend); + if (item == null) + continue; + + try + { + await _client.PostAsync($"api/inventory/{item.Id}/receipts", + new InventoryReceipt(Math.Round(kilograms, 2))); + } + catch + { + // The pallet couldn't be shelved; leave it on the dock for the + // next collection + _loadingDock.Deliver(blend, kilograms); + throw; + } + } + } } \ No newline at end of file diff --git a/src/Roastery/Api/InventoryController.cs b/src/Roastery/Api/InventoryController.cs new file mode 100644 index 00000000..8e48a2d4 --- /dev/null +++ b/src/Roastery/Api/InventoryController.cs @@ -0,0 +1,64 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Roastery.Data; +using Roastery.Metrics; +using Roastery.Model; +using Roastery.Web; +using Serilog; +using Serilog.Context; + +// ReSharper disable UnusedType.Global + +namespace Roastery.Api; + +class InventoryController : Controller +{ + readonly Database _database; + + public InventoryController(ILogger logger, RoasteryWebMetrics metrics, Database database) + : base(logger, metrics) + { + _database = database; + } + + [Route("GET", "api/inventory")] + public async Task List(HttpRequest request) + { + var inventory = await _database.SelectAsync(); + + foreach (var item in inventory) + Metrics.RecordStockLevel(new RoasteryWebMetrics.Sample.StockLevelKey(item.Blend), item.QuantityKilograms); + + return Json(inventory); + } + + [Route("POST", "api/inventory/{id}/receipts")] + public async Task ReceiveStock(HttpRequest request) + { + if (request.Body == null) + return BadRequest("A stock receipt is required."); + + var receipt = (InventoryReceipt)request.Body; + var inventoryId = request.Path.Split('/')[3]; + + var inventory = (await _database.SelectAsync(i => i.Id == inventoryId, $"id = '{inventoryId}'")) + .SingleOrDefault(); + if (inventory == null) + return NotFound(); + + using var _ = LogContext.PushProperty("InventoryId", inventory.Id); + + inventory.QuantityKilograms = Math.Round(inventory.QuantityKilograms + receipt.Kilograms, 2); + await _database.UpdateAsync(inventory, + $"quantitykilograms = {inventory.QuantityKilograms.ToString(CultureInfo.InvariantCulture)}"); + + Metrics.RecordStockLevel(new RoasteryWebMetrics.Sample.StockLevelKey(inventory.Blend), + inventory.QuantityKilograms); + Log.Information("Received {ReceiptKilograms}kg of {Blend} into the warehouse; stock is now {StockKilograms}kg", + receipt.Kilograms, inventory.Blend, inventory.QuantityKilograms); + + return Json(inventory); + } +} \ No newline at end of file diff --git a/src/Roastery/Api/OrdersController.cs b/src/Roastery/Api/OrdersController.cs index 35c34eb5..254b1650 100644 --- a/src/Roastery/Api/OrdersController.cs +++ b/src/Roastery/Api/OrdersController.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Globalization; +using System.Linq; using System.Net; using System.Threading.Tasks; using Roastery.Data; @@ -16,7 +18,7 @@ class OrdersController : Controller { readonly Database _database; - public OrdersController(ILogger logger, RoasteryMetrics metrics, Database database) + public OrdersController(ILogger logger, RoasteryWebMetrics metrics, Database database) : base(logger, metrics) { _database = database; @@ -33,19 +35,19 @@ public async Task Create(HttpRequest request) { if (request.Body == null) return BadRequest("An order is required."); - - var order = (Order) request.Body; + + var order = (Order)request.Body; if (order.CustomerName == null) { return BadRequest("To create an order, a customer name is required."); } - + await _database.InsertAsync(order); - + Metrics.RecordOrderCreated(); Log.Information("Created new order {OrderId} for customer {CustomerName}", order.Id, order.CustomerName); - + return Json(order, HttpStatusCode.Created); } @@ -54,17 +56,18 @@ public async Task Update(HttpRequest request) { if (request.Body == null) return BadRequest("An order is required."); - - var order = (Order) request.Body; + + var order = (Order)request.Body; using var _ = LogContext.PushProperty("OrderId", order.Id); - var existing = (await _database.SelectAsync(o => o.Id == order.Id, $"id = '{order.Id}'")).SingleOrDefault(); + var existing = + (await _database.SelectAsync(o => o.Id == order.Id, $"id = '{order.Id}'")).SingleOrDefault(); if (existing == null) return NotFound(); if (order.Status == existing.Status) return BadRequest($"The order is already in the {order.Status} state"); - + await _database.UpdateAsync(order, $"status = '{order.Status}'"); if (order.Status == OrderStatus.PendingShipment) Log.Information("Order placed and ready for shipment"); @@ -76,45 +79,99 @@ public async Task Update(HttpRequest request) } else Log.Information("Order updated"); - + return OK(); } - + [Route("DELETE", "api/orders/{id}")] public async Task Delete(HttpRequest request) { var orderId = request.Path.Substring("/api/orders/".Length); using var _ = LogContext.PushProperty("OrderId", orderId); - if (!(await _database.SelectAsync(o => o.Id == orderId, $"id = '{orderId}'")).Any()) + var order = (await _database.SelectAsync(o => o.Id == orderId, $"id = '{orderId}'")).SingleOrDefault(); + if (order == null) return NotFound(); + // Items in unshipped orders have stock reserved for them; deleting the + // order returns that stock to the warehouse + if (order.Status != OrderStatus.Shipped) + await ReturnItemsToStock(orderId); + await _database.DeleteAsync(o => o.OrderId == orderId, $"orderid = '{orderId}'"); await _database.DeleteAsync(o => o.Id == orderId, $"id = '{orderId}'"); Log.Information("Order deleted"); - + return OK(); } - + + async Task ReturnItemsToStock(string orderId) + { + var items = await _database.SelectAsync(i => i.OrderId == orderId, $"orderid = '{orderId}'"); + if (items.Count == 0) + return; + + var products = (await _database.SelectAsync()).ToDictionary(p => p.Id!); + + foreach (var byBlend in items + .Where(i => products.ContainsKey(i.ProductId)) + .GroupBy(i => products[i.ProductId].Blend)) + { + var returnedKilograms = byBlend.Sum(i => products[i.ProductId].SizeInGrams) / 1000.0; + + var inventory = + (await _database.SelectAsync(iv => iv.Blend == byBlend.Key, $"blend = '{byBlend.Key}'")) + .SingleOrDefault(); + if (inventory == null) + continue; + + inventory.QuantityKilograms = Math.Round(inventory.QuantityKilograms + returnedKilograms, 2); + await _database.UpdateAsync(inventory, + $"quantitykilograms = {inventory.QuantityKilograms.ToString(CultureInfo.InvariantCulture)}"); + + Metrics.RecordStockLevel(new RoasteryWebMetrics.Sample.StockLevelKey(inventory.Blend), + inventory.QuantityKilograms); + Log.Information("Returned {ReturnedKilograms}kg of {Blend} to stock", returnedKilograms, byBlend.Key); + } + } + [Route("POST", "api/orders/{id}/items")] public async Task AddItem(HttpRequest request) { if (request.Body == null) return BadRequest("An order item is required."); - - var item = (OrderItem) request.Body; - var order = (await _database.SelectAsync(o => o.Id == item.OrderId, $"id = '{item.OrderId}'")).SingleOrDefault(); + + var item = (OrderItem)request.Body; + var order = (await _database.SelectAsync(o => o.Id == item.OrderId, $"id = '{item.OrderId}'")) + .SingleOrDefault(); if (order == null) return NotFound(); using var _ = LogContext.PushProperty("OrderId", order.Id); - var product = (await _database.SelectAsync(p => p.Id == item.ProductId, $"id = '{item.ProductId}'")).SingleOrDefault(); + var product = (await _database.SelectAsync(p => p.Id == item.ProductId, $"id = '{item.ProductId}'")) + .SingleOrDefault(); if (product == null) return NotFound(); using var __ = LogContext.PushProperty("ProductId", product.Id); + var requiredKilograms = product.SizeInGrams / 1000.0; + var inventory = + (await _database.SelectAsync(i => i.Blend == product.Blend, $"blend = '{product.Blend}'")) + .SingleOrDefault(); + if (inventory == null || inventory.QuantityKilograms < requiredKilograms) + { + Log.Warning("Product {ProductId} is out of stock", product.Id); + return Conflict($"Insufficient stock of {product.Blend}."); + } + + inventory.QuantityKilograms = Math.Round(inventory.QuantityKilograms - requiredKilograms, 2); + await _database.UpdateAsync(inventory, + $"quantitykilograms = {inventory.QuantityKilograms.ToString(CultureInfo.InvariantCulture)}"); + Metrics.RecordStockLevel(new RoasteryWebMetrics.Sample.StockLevelKey(inventory.Blend), + inventory.QuantityKilograms); + await _database.InsertAsync(item); Log.Information("Added 1 x product {@Product} to order", new { product.Name, product.SizeInGrams }); diff --git a/src/Roastery/Api/ProductsController.cs b/src/Roastery/Api/ProductsController.cs index dacd5e2b..22e2991d 100644 --- a/src/Roastery/Api/ProductsController.cs +++ b/src/Roastery/Api/ProductsController.cs @@ -13,7 +13,7 @@ class ProductsController : Controller { readonly Database _database; - public ProductsController(ILogger logger, RoasteryMetrics metrics, Database database) + public ProductsController(ILogger logger, RoasteryWebMetrics metrics, Database database) : base(logger, metrics) { _database = database; diff --git a/src/Roastery/Data/Database.cs b/src/Roastery/Data/Database.cs index a166d5b7..9234f1ce 100644 --- a/src/Roastery/Data/Database.cs +++ b/src/Roastery/Data/Database.cs @@ -18,32 +18,33 @@ class Database readonly ILogger _logger; readonly object _sync = new(); readonly IDictionary _data = new Dictionary(); - + public Database(ILogger logger, string schemaName) { _schemaName = schemaName; _logger = logger.ForContext(); } - public void BulkLoad(params T[] rows) where T: IIdentifiable, new() + public void BulkLoad(params T[] rows) where T : IIdentifiable, new() { lock (_sync) { foreach (var row in rows) { - _data.Add(row.Id ?? throw new ArgumentException("Rows must have an id assigned before insertion."), Clone(row)); + _data.Add(row.Id ?? throw new ArgumentException("Rows must have an id assigned before insertion."), + Clone(row)); } } } - public async Task> SelectAsync() where T: IIdentifiable, new() + public async Task> SelectAsync() where T : IIdentifiable, new() { return await SelectAsync(null, null); } - + public async Task> SelectAsync( Func? predicate, - string? where) where T: IIdentifiable, new() + string? where) where T : IIdentifiable, new() { // Not how you should build SQL at home, folks ;-) var sql = $"select * from {TableName()}"; @@ -58,45 +59,49 @@ public async Task> SelectAsync( return rows; } - public async Task InsertAsync(T row) where T: IIdentifiable, new() + public async Task InsertAsync(T row) where T : IIdentifiable, new() { row.Id = typeof(T).Name.ToLowerInvariant() + "-" + Guid.NewGuid().ToString("n").Substring(10); - + lock (_sync) _data.Add(row.Id, Clone(row)); var columns = typeof(T).GetTypeInfo().DeclaredProperties .Where(p => p.CanRead && p.GetMethod!.IsPublic && !p.GetMethod.IsStatic && - (p.PropertyType.IsPrimitive || p.PropertyType.IsEnum || p.PropertyType == typeof(string) || p.PropertyType == typeof(DateTime))) + (p.PropertyType.IsPrimitive || p.PropertyType.IsEnum || p.PropertyType == typeof(string) || + p.PropertyType == typeof(DateTime))) .Where(p => p.Name != nameof(IIdentifiable.Id)) .ToDictionary( p => p.Name.ToLowerInvariant(), p => AsSqlLiteral(p.GetValue(row))); - - var sql = $"insert into {TableName()} ({string.Join(", ", columns.Keys)}) values ({string.Join(", ", columns.Values)}) returning id;"; + + var sql = + $"insert into {TableName()} ({string.Join(", ", columns.Keys)}) values ({string.Join(", ", columns.Values)}) returning id;"; await LogExecAsync(sql, 1); } - - public async Task UpdateAsync(T row, string updatedColumns) where T: IIdentifiable, new() + + public async Task UpdateAsync(T row, string updatedColumns) where T : IIdentifiable, new() { var rows = 0; lock (_sync) { - if (_data.TryGetValue(row.Id ?? throw new ArgumentException("Rows must have an id assigned before updating."), out var existing) && + if (_data.TryGetValue( + row.Id ?? throw new ArgumentException("Rows must have an id assigned before updating."), + out var existing) && existing is T) { rows = 1; _data[row.Id] = Clone(row); } } - + var sql = $"update {TableName()} set {updatedColumns} where id = '{row.Id}';"; await LogExecAsync(sql, rows); } - + public async Task DeleteAsync( Func predicate, - string where) where T: IIdentifiable, new() + string where) where T : IIdentifiable, new() { int rows; lock (_sync) @@ -112,10 +117,10 @@ public async Task DeleteAsync( { _data.Remove(id); } - + rows = ids.Count; } - + var sql = $"delete from {TableName()} where {where};"; await LogExecAsync(sql, rows); } @@ -126,7 +131,7 @@ static string AsSqlLiteral(object? o) if (o is string s) return $"'{s.Replace("'", "''")}'"; if (o is DateTime dt) return $"'{dt:o}'"; if (o.GetType().GetTypeInfo().IsEnum) return $"'{o}'"; - return ((IFormattable) o).ToString(null, CultureInfo.InvariantCulture); + return ((IFormattable)o).ToString(null, CultureInfo.InvariantCulture); } string TableName() where T : IIdentifiable, new() @@ -142,20 +147,20 @@ async Task LogExecAsync(string sql, int rowCount) { var exception = new OperationCanceledException( "A deadlock was detected and the transaction chosen as the deadlock victim."); - + activity.Complete(LogEventLevel.Error, exception); throw exception; } - - + + var delay = 10 + (int)(Distribution.Uniform() * Math.Pow(rowCount, 1.6)); await Task.Delay(delay); - + activity.AddProperty("RowCount", rowCount); } - static T Clone(T value) where T: IIdentifiable, new() + static T Clone(T value) where T : IIdentifiable, new() { var dest = new T(); var cloneable = typeof(T).GetTypeInfo().DeclaredProperties diff --git a/src/Roastery/Data/DatabaseMigrator.cs b/src/Roastery/Data/DatabaseMigrator.cs index 9cd32297..51634182 100644 --- a/src/Roastery/Data/DatabaseMigrator.cs +++ b/src/Roastery/Data/DatabaseMigrator.cs @@ -1,23 +1,32 @@ -using Roastery.Model; +using Roastery.Model; namespace Roastery.Data; static class DatabaseMigrator { + const string RocketShip = "Rocket Ship Dark Roast"; + const string OneAm = "1 AM Medium Roast"; + public static void Populate(Database database) { database.BulkLoad( - new Product("Rocket Ship Dark Roast, Whole Beans", 100) {Id = "product-8908fd0sa"}, - new Product("Rocket Ship Dark Roast, Whole Beans", 250) {Id = "product-fsad890fj"}, - new Product("Rocket Ship Dark Roast, Whole Beans", 1000) {Id = "product-fsdjkljrw"}, - new Product("Rocket Ship Dark Roast, Ground", 100) {Id = "product-2nkfkdsju"}, - new Product("Rocket Ship Dark Roast, Ground", 250) {Id = "product-f8sa9newq"}, - new Product("Rocket Ship Dark Roast, Ground", 1000) {Id = "product-cvsad9033"}, - new Product("1 AM Medium Roast, Whole Beans", 100) {Id = "product-i908jd0sf"}, - new Product("1 AM Medium Roast, Whole Beans", 250) {Id = "product-isadj90fd"}, - new Product("1 AM Medium Roast, Whole Beans", 1000) {Id = "product-isdjjljr3"}, - new Product("1 AM Medium Roast, Ground", 100) {Id = "product-inkfjdsj2"}, - new Product("1 AM Medium Roast, Ground", 250) {Id = "product-i8sajnew1"}, - new Product("1 AM Medium Roast, Ground", 1000) {Id = "product-ivsaj903t"}); + new Product(RocketShip, "Rocket Ship Dark Roast, Whole Beans", 100) { Id = "product-8908fd0sa" }, + new Product(RocketShip, "Rocket Ship Dark Roast, Whole Beans", 250) { Id = "product-fsad890fj" }, + new Product(RocketShip, "Rocket Ship Dark Roast, Whole Beans", 1000) { Id = "product-fsdjkljrw" }, + new Product(RocketShip, "Rocket Ship Dark Roast, Ground", 100) { Id = "product-2nkfkdsju" }, + new Product(RocketShip, "Rocket Ship Dark Roast, Ground", 250) { Id = "product-f8sa9newq" }, + new Product(RocketShip, "Rocket Ship Dark Roast, Ground", 1000) { Id = "product-cvsad9033" }, + new Product(OneAm, "1 AM Medium Roast, Whole Beans", 100) { Id = "product-i908jd0sf" }, + new Product(OneAm, "1 AM Medium Roast, Whole Beans", 250) { Id = "product-isadj90fd" }, + new Product(OneAm, "1 AM Medium Roast, Whole Beans", 1000) { Id = "product-isdjjljr3" }, + new Product(OneAm, "1 AM Medium Roast, Ground", 100) { Id = "product-inkfjdsj2" }, + new Product(OneAm, "1 AM Medium Roast, Ground", 250) { Id = "product-i8sajnew1" }, + new Product(OneAm, "1 AM Medium Roast, Ground", 1000) { Id = "product-ivsaj903t" }); + + // Starting stock sits just below the warehouse's reorder point, so + // production kicks off as soon as the simulation starts + database.BulkLoad( + new Inventory(RocketShip, 280) { Id = "inventory-88dfk01ka" }, + new Inventory(OneAm, 280) { Id = "inventory-73hdka92f" }); } } \ No newline at end of file diff --git a/src/Roastery/Fake/Person.cs b/src/Roastery/Fake/Person.cs index 56c0ae0c..8aeb7696 100644 --- a/src/Roastery/Fake/Person.cs +++ b/src/Roastery/Fake/Person.cs @@ -41,7 +41,7 @@ public Person(string? name, string? address) "Zach", "Zeynep" ]; - + static readonly string[] Surnames = [ "Anderson", @@ -61,7 +61,7 @@ public Person(string? name, string? address) "Xia", "Zheng" ]; - + static readonly string[] Streets = [ "Lilac Road", @@ -82,11 +82,11 @@ public Person(string? name, string? address) public static Person Generate() { - var streetNumber = (int) Distribution.Uniform(1.0, 1000); + var streetNumber = (int)Distribution.Uniform(1.0, 1000); var name = $"{Distribution.Uniform(Forenames)} {Distribution.Uniform(Surnames)}"; if (Distribution.OnceIn(20)) name += $"-{Distribution.Uniform(Surnames)}"; - + return new Person( name, $"{streetNumber} {Distribution.Uniform(Streets)}"); diff --git a/src/Roastery/Metrics/ExponentialHistogram.cs b/src/Roastery/Metrics/ExponentialHistogram.cs index c5d39b22..dd9ae58f 100644 --- a/src/Roastery/Metrics/ExponentialHistogram.cs +++ b/src/Roastery/Metrics/ExponentialHistogram.cs @@ -11,14 +11,14 @@ public ExponentialHistogram(int initialScale = 20, int targetBuckets = 160) _targetBuckets = targetBuckets; _buckets = new Dictionary(); } - + readonly int _targetBuckets; int _scale; Dictionary _buckets; - double _min; - double _max; + double _min = double.PositiveInfinity; + double _max = double.NegativeInfinity; double _sum; ulong _total; @@ -28,13 +28,13 @@ public void Record(double 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(); @@ -54,13 +54,13 @@ 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; diff --git a/src/Roastery/Metrics/PropertyNameMapping.cs b/src/Roastery/Metrics/PropertyNameMapping.cs index 28a7f28e..087d8793 100644 --- a/src/Roastery/Metrics/PropertyNameMapping.cs +++ b/src/Roastery/Metrics/PropertyNameMapping.cs @@ -1,3 +1,3 @@ namespace Roastery.Metrics; -public record struct PropertyNameMapping(string MetricDefinitions); +public record struct PropertyNameMapping(string MetricDefinitions); \ No newline at end of file diff --git a/src/Roastery/Metrics/RoasteryMetrics.cs b/src/Roastery/Metrics/RoasteryMetrics.cs index 12a4c08a..0ebd42d8 100644 --- a/src/Roastery/Metrics/RoasteryMetrics.cs +++ b/src/Roastery/Metrics/RoasteryMetrics.cs @@ -10,153 +10,41 @@ namespace Roastery.Metrics; -public class RoasteryMetrics +/* +Adding new metrics: + +1. Pick the metrics class for the producing application: `RoasteryWebMetrics` for the web frontend, + `RoasteryProductionMetrics` for the roasting plant. +2. Add a new key type, `TKey` for the metric's attributes using structural equality. Keys can carry + arbitrary, high-cardinality attributes: each distinct key only lives for a single sampling + interval. +3. Add a `Dictionary` property to the class's `Sample` where `TMetric` is the + metric's collection type. Counters and histograms are deltas — they accumulate within a sampling + interval and are reset when the sample is taken. Gauges store the raw sampled value, + last-write-wins. +4. Add a method to the metrics class to add a sample to the metric for a given key. +5. Add support in the `Sample`'s `ToLogEvents` for the new metric. +*/ + +public abstract class RoasteryMetrics + where TSample : RoasteryMetricsSample, new() { - 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; - } - } + TSample _current = new(); - public void RecordOrderShipped() + protected void Record(Action record) { lock (_lock) { - _current.OrderShipped += 1; + record(_current); } } - public (DateTimeOffset, Sample) Take() + public (DateTimeOffset, TSample) Take() { var timestamp = DateTimeOffset.UtcNow; - - var current = new Sample(); + + var current = new TSample(); lock (_lock) { @@ -166,10 +54,9 @@ public void RecordOrderShipped() return (timestamp, current); } - public static Task PeriodicSample( - RoasteryMetrics metrics, + public Task PeriodicSample( TimeSpan samplingInterval, - Func sample, + Func sample, CancellationToken cancellationToken) { return Task.Run(async () => @@ -183,7 +70,7 @@ public static Task PeriodicSample( try { - var (timestamp, current) = metrics.Take(); + var (timestamp, current) = Take(); await sample(timestamp, current, cancellationToken); } catch @@ -199,3 +86,46 @@ public static Task PeriodicSample( }, cancellationToken); } } + +public abstract class RoasteryMetricsSample +{ + static readonly MessageTemplate Template = new MessageTemplateParser().Parse("Metrics sampled"); + + public abstract IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, + DateTimeOffset timestamp); + + protected static object ToHistogramValue(ExponentialHistogram metric) + { + return 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 + }; + } + + protected 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); + } +} \ No newline at end of file diff --git a/src/Roastery/Metrics/RoasteryProductionMetrics.cs b/src/Roastery/Metrics/RoasteryProductionMetrics.cs new file mode 100644 index 00000000..2a8fa900 --- /dev/null +++ b/src/Roastery/Metrics/RoasteryProductionMetrics.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using Serilog; +using Serilog.Events; + +namespace Roastery.Metrics; + +public class RoasteryProductionMetrics : RoasteryMetrics +{ + public class Sample : RoasteryMetricsSample + { + public record struct RoastKey(string MachineId, string RoastId, string RoastProfile); + + public record struct RoastTelemetryGauges( + double BeanTemperature, + double RateOfRise, + double BurnerLevel, + double DrumSpeed); + + public readonly Dictionary RoastTelemetry = new(); + + public readonly Dictionary RoastBatchesStarted = new(); + public readonly Dictionary RoastBatchesCompleted = new(); + public readonly Dictionary RoastBatchesRejected = new(); + + public readonly Dictionary RoastDuration = new(); + public readonly Dictionary RoastWeightLoss = new(); + + public record struct AmbientKey(string Area); + + public record struct AmbientGauges( + double AmbientTemperature, + double AmbientRelativeHumidity, + double AmbientBarometricPressure); + + public readonly Dictionary Ambient = new(); + + public override IEnumerable ToLogEvents(ILogger logger, PropertyNameMapping propertyNameMapping, + DateTimeOffset timestamp) + { + foreach (var (key, gauges) in RoastTelemetry) + { + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [nameof(RoastTelemetryGauges.BeanTemperature)] = new + { + kind = "Gauge", + unit = "Cel", + description = "The temperature reported by the bean mass probe." + }, + [nameof(RoastTelemetryGauges.RateOfRise)] = new + { + kind = "Gauge", + unit = "Cel/min", + description = "The rate of change of the bean mass temperature." + }, + [nameof(RoastTelemetryGauges.BurnerLevel)] = new + { + kind = "Gauge", + unit = "%", + description = "The burner power level applied to the roasting drum." + }, + [nameof(RoastTelemetryGauges.DrumSpeed)] = new + { + kind = "Gauge", + unit = "rpm", + description = "The rotational speed of the roasting drum." + } + }, + new Dictionary + { + [nameof(RoastTelemetryGauges.BeanTemperature)] = gauges.BeanTemperature, + [nameof(RoastTelemetryGauges.RateOfRise)] = gauges.RateOfRise, + [nameof(RoastTelemetryGauges.BurnerLevel)] = gauges.BurnerLevel, + [nameof(RoastTelemetryGauges.DrumSpeed)] = gauges.DrumSpeed, + [nameof(key.MachineId)] = key.MachineId, + [nameof(key.RoastId)] = key.RoastId, + [nameof(key.RoastProfile)] = key.RoastProfile + } + ); + } + + var counters = new (string Name, string Description, Dictionary Counter)[] + { + (nameof(RoastBatchesStarted), "A batch of green beans was charged into a roasting machine.", + RoastBatchesStarted), + (nameof(RoastBatchesCompleted), "A roast batch was dropped and passed quality control.", + RoastBatchesCompleted), + (nameof(RoastBatchesRejected), "A roast batch was rejected by quality control.", RoastBatchesRejected) + }; + + foreach (var (name, description, counter) in counters) + { + foreach (var (key, count) in counter) + { + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [name] = new + { + kind = "Sum", + unit = "batch", + description + } + }, + new Dictionary + { + [name] = count, + [nameof(key.MachineId)] = key.MachineId, + [nameof(key.RoastId)] = key.RoastId, + [nameof(key.RoastProfile)] = key.RoastProfile + } + ); + } + } + + var histograms = + new (string Name, string Unit, string Description, Dictionary Histogram) + [] + { + (nameof(RoastDuration), "s", "The time taken to roast a batch, from charge to drop.", + RoastDuration), + (nameof(RoastWeightLoss), "%", "The percentage of green bean weight lost during roasting.", + RoastWeightLoss) + }; + + foreach (var (name, unit, description, histogram) in histograms) + { + foreach (var (key, metric) in histogram) + { + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [name] = new + { + kind = "Exponential", + unit, + description + } + }, + new Dictionary + { + [name] = ToHistogramValue(metric), + [nameof(key.MachineId)] = key.MachineId, + [nameof(key.RoastId)] = key.RoastId, + [nameof(key.RoastProfile)] = key.RoastProfile + } + ); + } + } + + foreach (var (key, gauges) in Ambient) + { + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [nameof(AmbientGauges.AmbientTemperature)] = new + { + kind = "Gauge", + unit = "Cel", + description = "The ambient air temperature." + }, + [nameof(AmbientGauges.AmbientRelativeHumidity)] = new + { + kind = "Gauge", + unit = "%", + description = "The ambient relative humidity." + }, + [nameof(AmbientGauges.AmbientBarometricPressure)] = new + { + kind = "Gauge", + unit = "hPa", + description = "The ambient barometric pressure." + } + }, + new Dictionary + { + [nameof(AmbientGauges.AmbientTemperature)] = gauges.AmbientTemperature, + [nameof(AmbientGauges.AmbientRelativeHumidity)] = gauges.AmbientRelativeHumidity, + [nameof(AmbientGauges.AmbientBarometricPressure)] = gauges.AmbientBarometricPressure, + [nameof(key.Area)] = key.Area + } + ); + } + } + } + + public void RecordRoastTelemetry(Sample.RoastKey key, Sample.RoastTelemetryGauges gauges) + { + Record(sample => sample.RoastTelemetry[key] = gauges); + } + + public void RecordRoastBatchStarted(Sample.RoastKey key) + { + Record(sample => + { + sample.RoastBatchesStarted.TryAdd(key, 0); + sample.RoastBatchesStarted[key] += 1; + }); + } + + public void RecordRoastBatchCompleted(Sample.RoastKey key, double durationSeconds, double weightLossPercent) + { + Record(sample => + { + sample.RoastBatchesCompleted.TryAdd(key, 0); + sample.RoastBatchesCompleted[key] += 1; + + if (!sample.RoastDuration.TryGetValue(key, out var duration)) + { + duration = new ExponentialHistogram(); + sample.RoastDuration.Add(key, duration); + } + + duration.Record(durationSeconds); + + if (!sample.RoastWeightLoss.TryGetValue(key, out var weightLoss)) + { + weightLoss = new ExponentialHistogram(); + sample.RoastWeightLoss.Add(key, weightLoss); + } + + weightLoss.Record(weightLossPercent); + }); + } + + public void RecordRoastBatchRejected(Sample.RoastKey key) + { + Record(sample => + { + sample.RoastBatchesRejected.TryAdd(key, 0); + sample.RoastBatchesRejected[key] += 1; + }); + } + + public void RecordAmbientConditions(Sample.AmbientKey key, Sample.AmbientGauges gauges) + { + Record(sample => sample.Ambient[key] = gauges); + } +} \ No newline at end of file diff --git a/src/Roastery/Metrics/RoasteryWebMetrics.cs b/src/Roastery/Metrics/RoasteryWebMetrics.cs new file mode 100644 index 00000000..70b612b5 --- /dev/null +++ b/src/Roastery/Metrics/RoasteryWebMetrics.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using Serilog; +using Serilog.Events; + +namespace Roastery.Metrics; + +public class RoasteryWebMetrics : RoasteryMetrics +{ + public class Sample : RoasteryMetricsSample + { + public record struct HttpRequestDurationKey(string Path, int StatusCode); + + public readonly Dictionary HttpRequestDuration = new(); + + public ulong OrderCreated; + + public ulong OrderShipped; + + public record struct StockLevelKey(string Blend); + + public readonly Dictionary StockLevel = new(); + + public override 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)] = ToHistogramValue(metric), + [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 + } + ); + + foreach (var (key, level) in StockLevel) + { + yield return ToLogEvent( + logger, + propertyNameMapping, + timestamp, + new Dictionary + { + [nameof(StockLevel)] = new + { + kind = "Gauge", + unit = "kg", + description = "The current warehouse stock of a blend, available to fulfil orders." + } + }, + new Dictionary + { + [nameof(StockLevel)] = level, + [nameof(key.Blend)] = key.Blend + } + ); + } + } + } + + public void RecordHttpRequestDuration(Sample.HttpRequestDurationKey key, double rawValue) + { + Record(sample => + { + if (!sample.HttpRequestDuration.TryGetValue(key, out var metric)) + { + metric = new ExponentialHistogram(); + sample.HttpRequestDuration.Add(key, metric); + } + + metric.Record(rawValue); + }); + } + + public void RecordOrderCreated() + { + Record(sample => sample.OrderCreated += 1); + } + + public void RecordOrderShipped() + { + Record(sample => sample.OrderShipped += 1); + } + + public void RecordStockLevel(Sample.StockLevelKey key, double kilograms) + { + Record(sample => sample.StockLevel[key] = kilograms); + } +} \ No newline at end of file diff --git a/src/Roastery/Model/Inventory.cs b/src/Roastery/Model/Inventory.cs new file mode 100644 index 00000000..cac6ce52 --- /dev/null +++ b/src/Roastery/Model/Inventory.cs @@ -0,0 +1,24 @@ +using System; +using Roastery.Data; + +namespace Roastery.Model; + +class Inventory : IIdentifiable +{ + public string? Id { get; set; } + public string Blend { get; set; } + public double QuantityKilograms { get; set; } + + [Obsolete("Serialization constructor.")] +#pragma warning disable 8618 + public Inventory() + { + } +#pragma warning restore 8618 + + public Inventory(string blend, double quantityKilograms) + { + Blend = blend; + QuantityKilograms = quantityKilograms; + } +} \ No newline at end of file diff --git a/src/Roastery/Model/InventoryReceipt.cs b/src/Roastery/Model/InventoryReceipt.cs new file mode 100644 index 00000000..be942df8 --- /dev/null +++ b/src/Roastery/Model/InventoryReceipt.cs @@ -0,0 +1,3 @@ +namespace Roastery.Model; + +record InventoryReceipt(double Kilograms); \ No newline at end of file diff --git a/src/Roastery/Model/LoadingDock.cs b/src/Roastery/Model/LoadingDock.cs new file mode 100644 index 00000000..8a3a802f --- /dev/null +++ b/src/Roastery/Model/LoadingDock.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Threading; + +namespace Roastery.Model; + +class LoadingDock +{ + readonly Lock _sync = new(); + readonly Dictionary _pallets = new(); + + public void Deliver(string blend, double kilograms) + { + lock (_sync) + { + _pallets.TryAdd(blend, 0); + _pallets[blend] += kilograms; + } + } + + public IReadOnlyDictionary Collect() + { + lock (_sync) + { + var collected = new Dictionary(_pallets); + _pallets.Clear(); + return collected; + } + } +} \ No newline at end of file diff --git a/src/Roastery/Model/MaintenanceSchedule.cs b/src/Roastery/Model/MaintenanceSchedule.cs new file mode 100644 index 00000000..93886e83 --- /dev/null +++ b/src/Roastery/Model/MaintenanceSchedule.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using Roastery.Util; + +namespace Roastery.Model; + +class MaintenanceSchedule +{ + readonly Lock _sync = new(); + + DateTime _outageStart; + DateTime _outageEnd; + + public MaintenanceSchedule(DateTime outageStart) + { + _outageStart = outageStart; + _outageEnd = _outageStart + OutageDuration(); + } + + public bool IsUnderMaintenance() + { + lock (_sync) + { + var now = DateTime.UtcNow; + if (now < _outageStart) + return false; + + if (now < _outageEnd) + return true; + + // Schedule the next outage for roughly a day into the future + _outageStart = now + TimeSpan.FromHours(Distribution.Uniform(20, 28)); + _outageEnd = _outageStart + OutageDuration(); + return false; + } + } + + static TimeSpan OutageDuration() => TimeSpan.FromMinutes(Distribution.Uniform(6, 71)); +} \ No newline at end of file diff --git a/src/Roastery/Model/Order.cs b/src/Roastery/Model/Order.cs index 096febbd..3637bc47 100644 --- a/src/Roastery/Model/Order.cs +++ b/src/Roastery/Model/Order.cs @@ -6,12 +6,12 @@ namespace Roastery.Model; class Order : IIdentifiable { public string? Id { get; set; } - + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - + // Nullable so we can emulate invalid requests :-) public string? CustomerName { get; set; } public string? ShippingAddress { get; set; } - + public OrderStatus Status { get; set; } } \ No newline at end of file diff --git a/src/Roastery/Model/OrderItem.cs b/src/Roastery/Model/OrderItem.cs index 256e75f6..b8850782 100644 --- a/src/Roastery/Model/OrderItem.cs +++ b/src/Roastery/Model/OrderItem.cs @@ -3,15 +3,17 @@ namespace Roastery.Model; -public class OrderItem: IIdentifiable +public class OrderItem : IIdentifiable { public string? Id { get; set; } public string OrderId { get; set; } public string ProductId { get; set; } - + [Obsolete("Serialization constructor.")] #pragma warning disable 8618 - public OrderItem() { } + public OrderItem() + { + } #pragma warning restore 8618 public OrderItem(string orderId, string productId) diff --git a/src/Roastery/Model/Product.cs b/src/Roastery/Model/Product.cs index 05f2f004..7badc834 100644 --- a/src/Roastery/Model/Product.cs +++ b/src/Roastery/Model/Product.cs @@ -3,21 +3,25 @@ namespace Roastery.Model; -class Product: IIdentifiable +class Product : IIdentifiable { public string? Id { get; set; } + public string Blend { get; set; } public string Name { get; set; } public int SizeInGrams { get; set; } - - public string FormatDescription() => $"{Name} {SizeInGrams}g"; + + public string FormatDescription() => $"{Name} {SizeInGrams}g"; [Obsolete("Serialization constructor.")] #pragma warning disable 8618 - public Product() { } + public Product() + { + } #pragma warning restore 8618 - - public Product(string name, int sizeInGrams) + + public Product(string blend, string name, int sizeInGrams) { + Blend = blend; Name = name; SizeInGrams = sizeInGrams; } diff --git a/src/Roastery/Model/ProductionSchedule.cs b/src/Roastery/Model/ProductionSchedule.cs new file mode 100644 index 00000000..b0f1cb87 --- /dev/null +++ b/src/Roastery/Model/ProductionSchedule.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.Threading; + +namespace Roastery.Model; + +class ProductionSchedule +{ + readonly Lock _sync = new(); + readonly Queue _requestedBlends = new(); + + public void Request(string blend) + { + lock (_sync) + { + if (!_requestedBlends.Contains(blend)) + _requestedBlends.Enqueue(blend); + } + } + + public string? TakeRequest() + { + lock (_sync) + { + return _requestedBlends.TryDequeue(out var blend) ? blend : null; + } + } +} \ No newline at end of file diff --git a/src/Roastery/Model/RoastProfile.cs b/src/Roastery/Model/RoastProfile.cs new file mode 100644 index 00000000..629fe9f5 --- /dev/null +++ b/src/Roastery/Model/RoastProfile.cs @@ -0,0 +1,7 @@ +namespace Roastery.Model; + +record RoastProfile( + string Name, + double DropTemperatureCelsius, + double FinalBurnerLevelPercent, + double TypicalWeightLossPercent); \ No newline at end of file diff --git a/src/Roastery/Program.cs b/src/Roastery/Program.cs index acef4206..bcaf2b7e 100644 --- a/src/Roastery/Program.cs +++ b/src/Roastery/Program.cs @@ -8,6 +8,7 @@ using Roastery.Data; using Roastery.Fake; using Roastery.Metrics; +using Roastery.Model; using Roastery.Util; using Roastery.Web; using Serilog; @@ -17,35 +18,56 @@ namespace Roastery; // Named this way to make stack traces a little more believable :-) public static class Program { - public static async Task Main(ILogger logger, PropertyNameMapping propertyNameMapping, CancellationToken cancellationToken = default) + public static async Task Main(ILogger logger, PropertyNameMapping propertyNameMapping, + CancellationToken cancellationToken = default) { - var metrics = new RoasteryMetrics(); - + var webMetrics = new RoasteryWebMetrics(); + var productionMetrics = new RoasteryProductionMetrics(); + var webApplicationLogger = logger.ForContext("Application", "Roastery Web Frontend"); + var productionLogger = logger.ForContext("Application", "Roastery Production"); // Sample metrics - var periodicSample = RoasteryMetrics.PeriodicSample(metrics, TimeSpan.FromSeconds(5), (timestamp, sample, ct) => + var sampleWebMetrics = webMetrics.PeriodicSample(TimeSpan.FromSeconds(5), (timestamp, sample, ct) => { foreach (var evt in sample.ToLogEvents(webApplicationLogger, propertyNameMapping, timestamp)) { webApplicationLogger.Write(evt); } - + return Task.CompletedTask; }, cancellationToken); + var sampleProductionMetrics = productionMetrics.PeriodicSample(TimeSpan.FromSeconds(5), + (timestamp, sample, ct) => + { + foreach (var evt in sample.ToLogEvents(productionLogger, propertyNameMapping, timestamp)) + { + productionLogger.Write(evt); + } + + return Task.CompletedTask; + }, cancellationToken); + var database = new Database(webApplicationLogger, "roastery"); DatabaseMigrator.Populate(database); + var loadingDock = new LoadingDock(); + var productionSchedule = new ProductionSchedule(); + + // Schedule the first outage for 2 hours into ingestion + var maintenanceSchedule = new MaintenanceSchedule(DateTime.UtcNow + TimeSpan.FromHours(Distribution.Uniform(2, 7))); + var client = new HttpClient( "https://roastery.datalust.co", new NetworkLatencyMiddleware( - new RequestLoggingMiddleware(webApplicationLogger, metrics, + new RequestLoggingMiddleware(webApplicationLogger, webMetrics, new SchedulingLatencyMiddleware( new FaultInjectionMiddleware(webApplicationLogger, new Router([ - new OrdersController(logger, metrics, database), - new ProductsController(logger, metrics, database) + new OrdersController(logger, webMetrics, database), + new ProductsController(logger, webMetrics, database), + new InventoryController(logger, webMetrics, database) ], webApplicationLogger)))))); var agents = new List(); @@ -54,13 +76,20 @@ public static async Task Main(ILogger logger, PropertyNameMapping propertyNameMa agents.Add(new Customer(client, Person.Generate(), (int)Distribution.Uniform(60000, 180000))); for (var i = 0; i < 3; ++i) - agents.Add(new WarehouseStaff(client)); + agents.Add(new WarehouseStaff(client, loadingDock, productionSchedule)); + + var rng = new Random(); + for (var i = 1; i <= 4; ++i) + agents.Add(new RoastingMachine(productionLogger, productionMetrics, loadingDock, productionSchedule, + maintenanceSchedule, $"roaster-{rng.Next():x8}")); + + agents.Add(new FacilitySensors(productionLogger, productionMetrics)); var batchApplicationLogger = logger.ForContext("Application", "Roastery Batch Processing"); agents.Add(new CatalogBatch(client, batchApplicationLogger)); agents.Add(new ArchivingBatch(client, batchApplicationLogger)); await Task.WhenAll(agents.Select(a => Agent.Run(a, cancellationToken))); - await periodicSample; + await Task.WhenAll(sampleWebMetrics, sampleProductionMetrics); } } \ No newline at end of file diff --git a/src/Roastery/README.md b/src/Roastery/README.md index a835da49..f59d08b4 100644 --- a/src/Roastery/README.md +++ b/src/Roastery/README.md @@ -6,7 +6,7 @@ coffee beans. Customers place orders through the website, and staff fulfill orders by shipping them and updating the site. -Back-end batch processes detect abandoned orders, and archive fulfilled +Back-end batch processes detect abandoned orders, and archive fulfilled orders so that the simulation doesn't exhaust memory :-). The simulation produces events from an MVC-like web stack and a @@ -17,7 +17,7 @@ data can be produced. ## How the simulation works -The simulation centers on a single instance of an in-memory data +The simulation centers on a single instance of an in-memory data structure modelling the HTTP stack from client through to MVC-style "controllers" on the server-side: @@ -59,15 +59,15 @@ abstract class HttpServer Between the `HttpClient` that consumes the outermost middleware layer, and the `Router` that dispatches requests to controllers, we have: - * `NetworkLatencyMiddleware` — injects a random delay between - the client sending a request, and the server-side middleware receiving it - * `RequestLoggingMiddleware` — emits an event with timing - information at the completion of each request, including exception - details if the request failed - * `FaultInjectionMiddleware` — randomly fails requests by throwing - various exceptions - * `SchedulingLatencyMiddleware` — slows request processing down, - and more so when there are multiple requests executing concurrently +* `NetworkLatencyMiddleware` — injects a random delay between + the client sending a request, and the server-side middleware receiving it +* `RequestLoggingMiddleware` — emits an event with timing + information at the completion of each request, including exception + details if the request failed +* `FaultInjectionMiddleware` — randomly fails requests by throwing + various exceptions +* `SchedulingLatencyMiddleware` — slows request processing down, + and more so when there are multiple requests executing concurrently The `RequestLoggingMiddleware` pushes `RequestId` onto the log context; this tags all events generated during a request with the same id. A user @@ -89,7 +89,7 @@ context to give some notion of the cause of events like database operations deeper in the stack. We might in future emulate Serilog's `IDiagnosticContext` and set these properties there instead. -Action methods are decorated with the `[Route]` attribute, are +Action methods are decorated with the `[Route]` attribute, are asynchronous, accept `HttpRequest`, and return `HttpResponse`. Controllers tend to log domain-level events: _an order was placed_ and @@ -100,8 +100,7 @@ so-on. The simulated database is a thread-safe in-memory dictionary. Operations act on the dictionary using simple operations and predicates, -with additional fake SQL generated and logged to illustrate what's -going on in the +with additional fake SQL generated and logged. Entities are cloned when they're inserted and when they're retrieved, so that some hygiene is maintained. @@ -126,6 +125,28 @@ activities, generally including domain identifiers like `OrderId` for some correlation. We might in future use the batch processes as a way to illustrate tracing across application boundaries. +## Metrics + +Metrics are collected without using the .NET `System.Diagnostics` or +OpenTelemetry SDKs: metrics are plain dictionaries keyed by record +structs, so arbitrary (and possibly high-cardinality) attributes like +`RoastId` are supported with structural equality and no +pre-registration. + +Each simulated application has its own metrics class: web frontend +metrics (HTTP request durations, order counters) live in +`RoasteryWebMetrics`, and roasting and ambient environment metrics +live in `RoasteryProductionMetrics`. Each is sampled independently +and emitted through its application's logger. The abstract +`RoasteryMetrics` base class provides the shared collection +infrastructure, and `RoasteryMetricsSample` the conversion of samples +into `Metrics sampled` events. + +Counters and histograms have delta temporality: samples accumulate in +the current `Sample` instance, which is atomically swapped out every +five seconds and converted into log events. Gauges are instantaneous +last-write-wins values within each sampling interval. + ### See also... This is the data set relied upon by the entities created with diff --git a/src/Roastery/Roastery.csproj b/src/Roastery/Roastery.csproj index 5af15d3a..3d3c437f 100644 --- a/src/Roastery/Roastery.csproj +++ b/src/Roastery/Roastery.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/Roastery/Util/Distribution.cs b/src/Roastery/Util/Distribution.cs index 4a3af810..b20ffb49 100644 --- a/src/Roastery/Util/Distribution.cs +++ b/src/Roastery/Util/Distribution.cs @@ -14,17 +14,17 @@ public static double Uniform(double min, double max) { if (min < 0) throw new ArgumentOutOfRangeException(nameof(min)); - + if (max < min || max - min < double.Epsilon) throw new ArgumentOutOfRangeException(nameof(max)); - + return min + Rng.NextDouble() * (max - min); } [MethodImpl(MethodImplOptions.Synchronized)] public static T Uniform(IList items) { - var i = (int) Uniform(0, items.Count); + var i = (int)Uniform(0, items.Count); return items[i]; } diff --git a/src/Roastery/Web/Controller.cs b/src/Roastery/Web/Controller.cs index a16cd83b..5218d7dc 100644 --- a/src/Roastery/Web/Controller.cs +++ b/src/Roastery/Web/Controller.cs @@ -7,9 +7,9 @@ namespace Roastery.Web; abstract class Controller { protected ILogger Log { get; } - protected RoasteryMetrics Metrics { get; } - - protected Controller(ILogger logger, RoasteryMetrics metrics) + protected RoasteryWebMetrics Metrics { get; } + + protected Controller(ILogger logger, RoasteryWebMetrics metrics) { Log = logger.ForContext(GetType()); Metrics = metrics; @@ -31,6 +31,12 @@ protected HttpResponse NotFound() return new HttpResponse(HttpStatusCode.NotFound, "The resource was not found on this server."); } + protected HttpResponse Conflict(string? reason = null) + { + Log.Debug("Conflict: {Reason}", reason); + return new HttpResponse(HttpStatusCode.Conflict, reason); + } + protected HttpResponse OK() { return new HttpResponse(HttpStatusCode.OK, null); diff --git a/src/Roastery/Web/FaultInjectionMiddleware.cs b/src/Roastery/Web/FaultInjectionMiddleware.cs index 0c6f472e..4b1b28f0 100644 --- a/src/Roastery/Web/FaultInjectionMiddleware.cs +++ b/src/Roastery/Web/FaultInjectionMiddleware.cs @@ -8,7 +8,7 @@ namespace Roastery.Web; -class FaultInjectionMiddleware: HttpServer +class FaultInjectionMiddleware : HttpServer { readonly ILogger _logger; readonly HttpServer _next; @@ -42,7 +42,7 @@ static async Task Timeout(HttpRequest request) await Task.Delay(-1, cts.Token); throw new InvalidOperationException("Should never reach this."); } - + static Task Disposed(HttpRequest request) { throw new ObjectDisposedException("TcpConnection"); @@ -52,7 +52,7 @@ static Task Dropped() { throw new IOException("An operation was attempted on a nonexistent network connection."); } - + public override async Task InvokeAsync(HttpRequest request) { if (Distribution.OnceIn(220)) diff --git a/src/Roastery/Web/HttpClient.cs b/src/Roastery/Web/HttpClient.cs index 90a82d8f..98b0a9aa 100644 --- a/src/Roastery/Web/HttpClient.cs +++ b/src/Roastery/Web/HttpClient.cs @@ -34,7 +34,7 @@ public async Task PostAsync(string path, object body) var request = new HttpRequest(HttpMethod.Post, _basePath + path, body); var response = await _server.InvokeAsync(request); response.EnsureSuccessStatusCode(); - return (TResponse) response.Body!; + return (TResponse)response.Body!; } public async Task DeleteAsync(string path) diff --git a/src/Roastery/Web/HttpResponse.cs b/src/Roastery/Web/HttpResponse.cs index 0ed81321..afe11cb2 100644 --- a/src/Roastery/Web/HttpResponse.cs +++ b/src/Roastery/Web/HttpResponse.cs @@ -16,7 +16,7 @@ public HttpResponse(HttpStatusCode statusCode, object? body = null) public void EnsureSuccessStatusCode() { - if ((int) StatusCode >= 400) - throw new HttpRequestException($"Request failed with status code {(int) StatusCode}/{StatusCode}."); + if ((int)StatusCode >= 400) + throw new HttpRequestException($"Request failed with status code {(int)StatusCode}/{StatusCode}."); } } \ No newline at end of file diff --git a/src/Roastery/Web/NetworkLatencyMiddleware.cs b/src/Roastery/Web/NetworkLatencyMiddleware.cs index 8d19429c..73d6f616 100644 --- a/src/Roastery/Web/NetworkLatencyMiddleware.cs +++ b/src/Roastery/Web/NetworkLatencyMiddleware.cs @@ -16,7 +16,7 @@ public override async Task InvokeAsync(HttpRequest request) { await Task.Delay(100 + (int)(Distribution.Uniform() * 300)); var response = await _next.InvokeAsync(request); - await Task.Delay(10 + (int) (Distribution.Uniform() * 100)); + await Task.Delay(10 + (int)(Distribution.Uniform() * 100)); return response; } } \ No newline at end of file diff --git a/src/Roastery/Web/RequestLoggingMiddleware.cs b/src/Roastery/Web/RequestLoggingMiddleware.cs index 3219152a..c75a5002 100644 --- a/src/Roastery/Web/RequestLoggingMiddleware.cs +++ b/src/Roastery/Web/RequestLoggingMiddleware.cs @@ -15,9 +15,9 @@ class RequestLoggingMiddleware : HttpServer { readonly HttpServer _next; readonly ILogger _logger; - readonly RoasteryMetrics _metrics; - - public RequestLoggingMiddleware(ILogger logger, RoasteryMetrics metrics, HttpServer next) + readonly RoasteryWebMetrics _metrics; + + public RequestLoggingMiddleware(ILogger logger, RoasteryWebMetrics metrics, HttpServer next) { _next = next; _logger = logger.ForContext(); @@ -36,7 +36,9 @@ public override async Task InvokeAsync(HttpRequest request) var response = await _next.InvokeAsync(request); LogCompletion(activity, null, response.StatusCode); - _metrics.RecordHttpRequestDuration(new RoasteryMetrics.Sample.HttpRequestDurationKey(request.Path, (int)response.StatusCode), requestTiming.ElapsedMilliseconds); + _metrics.RecordHttpRequestDuration( + new RoasteryWebMetrics.Sample.HttpRequestDurationKey(request.Path, (int)response.StatusCode), + requestTiming.ElapsedMilliseconds); return response; } @@ -49,7 +51,9 @@ public override async Task InvokeAsync(HttpRequest request) { var statusCode = HttpStatusCode.InternalServerError; - _metrics.RecordHttpRequestDuration(new RoasteryMetrics.Sample.HttpRequestDurationKey(request.Path, (int)statusCode), requestTiming.ElapsedMilliseconds); + _metrics.RecordHttpRequestDuration( + new RoasteryWebMetrics.Sample.HttpRequestDurationKey(request.Path, (int)statusCode), + requestTiming.ElapsedMilliseconds); return new HttpResponse(statusCode, "An error occurred."); } } @@ -60,7 +64,7 @@ bool LogCompletion(LoggerActivity activity, Exception? exception, HttpStatusCode activity.AddProperty("StatusCode", (int)statusCode); activity.Complete(level, exception); - + return false; } } \ No newline at end of file diff --git a/src/Roastery/Web/Router.cs b/src/Roastery/Web/Router.cs index 0c9f7be9..31802e55 100644 --- a/src/Roastery/Web/Router.cs +++ b/src/Roastery/Web/Router.cs @@ -39,7 +39,7 @@ public RouteBinding(HttpMethod method, string template, string controller, strin public Router(IEnumerable controllers, ILogger logger) { _logger = logger.ForContext(); - + _logger.Debug("Building route table from controller metadata"); foreach (var controller in controllers) @@ -53,7 +53,7 @@ public Router(IEnumerable controllers, ILogger logger) var controllerName = controller.GetType().Name; var actionName = method.Name; var httpMethod = new HttpMethod(route!.Method.ToUpperInvariant()); - + var binding = new RouteBinding( httpMethod, route.Path, @@ -63,13 +63,15 @@ public Router(IEnumerable controllers, ILogger logger) { using var _ = LogContext.PushProperty("Controller", controllerName); using var __ = LogContext.PushProperty("Action", actionName); - return (Task) method.Invoke(controller, [r])!; + return (Task)method.Invoke(controller, [r])!; }); - - _logger.Debug("Binding route HTTP {HttpMethod} {RouteTemplate} to action method {Controller}.{Action}()", + + _logger.Debug( + "Binding route HTTP {HttpMethod} {RouteTemplate} to action method {Controller}.{Action}()", httpMethod, route.Path, binding.Controller, binding.Action); - var rx = new Regex("^" + route.Path.Replace("{id}", "[^/]+") + "$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + var rx = new Regex("^" + route.Path.Replace("{id}", "[^/]+") + "$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); _routes.Add((httpMethod, rx, binding)); } } diff --git a/src/Roastery/Web/SchedulingLatencyMiddleware.cs b/src/Roastery/Web/SchedulingLatencyMiddleware.cs index 47d9f1b4..07bfac80 100644 --- a/src/Roastery/Web/SchedulingLatencyMiddleware.cs +++ b/src/Roastery/Web/SchedulingLatencyMiddleware.cs @@ -26,8 +26,9 @@ public override async Task InvokeAsync(HttpRequest request) if (current > Capacity) { // One extra millisecond per concurrent request over capacity, ramping up - delay += (int) Math.Pow(current - Capacity, 1.6); + delay += (int)Math.Pow(current - Capacity, 1.6); } + await Task.Delay(delay); return await _next.InvokeAsync(request); } diff --git a/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs b/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs new file mode 100644 index 00000000..49ceba1d --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/CreateCommand.cs @@ -0,0 +1,223 @@ +// 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.Alerting; +using Seq.Api.Model.LogEvents; +using Seq.Api.Model.Shared; +using SeqCli.Api; +using SeqCli.Cli.Features; +using SeqCli.Config; +using SeqCli.Mapping; +using SeqCli.Signals; +using SeqCli.Syntax; +using SeqCli.Util; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "create", "Create an alert", + Example = "seqcli alert create -t 'Too many errors' --select \"count(*) as errors\" --from stream --signal signal-m33302 " + + "--where \"@Level = 'Error'\" --window 5m --having \"errors > 10\" --notification-level Error --suppression-time 10m")] +class CreateCommand : Command +{ + readonly ConnectionFeature _connection; + readonly OutputFormatFeature _output; + readonly StoragePathFeature _storagePath; + + readonly List _lateral = new(); + readonly List _select = new(); + readonly List _groupBy = new(); + readonly List _notificationApps = new(); + + string? _title, _description, _from, _signal, _where, _having, _window, _notificationLevel, _suppressionTime; + bool _isProtected, _isDisabled; + + public CreateCommand() + { + Options.Add( + "t=|title=", + "A title for the alert", + t => _title = ArgumentString.Normalize(t)); + + Options.Add( + "description=", + "A description for the alert", + d => _description = ArgumentString.Normalize(d)); + + Options.Add( + "from=", + "The data source the alert queries; either `stream` (the default) or `series`", + f => _from = ArgumentString.Normalize(f)); + + Options.Add( + "signal=", + "A signal expression limiting the alert's input, for example `signal-1` or `signal-1,signal-2`", + s => _signal = ArgumentString.Normalize(s)); + + Options.Add( + "lateral=", + "A lateral join over a set-valued function applied to the data source, in the form ` as `, for example `unnest(http.server.request.duration.buckets) as bucket`; this argument can be used multiple times", + l => _lateral.Add(ArgumentString.Normalize(l) ?? throw new ArgumentException("Lateral joins require a value."))); + + Options.Add( + "where=", + "A predicate that selects the events the alert will consider", + w => _where = ArgumentString.Normalize(w)); + + Options.Add( + "select=", + "A measurement the alert condition will test, for example `count(*) as errors`; this argument can be used multiple times", + s => _select.Add(ArgumentString.Normalize(s) ?? throw new ArgumentException("Measurements require a value."))); + + Options.Add( + "group-by=", + "An expression to group measurements by, for example `ServiceName` or `ServiceName ci as service`; the `ci` modifier makes the grouping case-insensitive; this argument can be used multiple times", + g => _groupBy.Add(ArgumentString.Normalize(g) ?? throw new ArgumentException("Groupings require a value."))); + + Options.Add( + "window=", + "The measurement window over which the alert condition is evaluated, as a duration, for example `1m` or `1h`", + w => _window = ArgumentString.Normalize(w)); + + Options.Add( + "having=", + "The alert condition; a predicate over the grouped measurements, for example `errors > 10`", + h => _having = ArgumentString.Normalize(h)); + + Options.Add( + "notification-level=", + "The level of the notifications raised by the alert, for example `Warning` or `Error`; the default is `Error`", + l => _notificationLevel = ArgumentString.Normalize(l)); + + Options.Add( + "suppression-time=", + "A duration for which notifications are suppressed after the alert triggers, for example `10m` or `1h`", + s => _suppressionTime = ArgumentString.Normalize(s)); + + Options.Add( + "notification-app=", + "The id of an app instance that will be notified when the alert triggers; this argument can be used multiple times", + a => _notificationApps.Add(ArgumentString.Normalize(a) ?? throw new ArgumentException("Notification apps require a value."))); + + Options.Add( + "protected", + "Specify that the alert is editable only by administrators", + _ => _isProtected = true); + + Options.Add( + "disabled", + "Create the alert in a disabled state; disabled alerts are not processed and do not send notifications", + _ => _isDisabled = true); + + _connection = Enable(); + _output = Enable(); + _storagePath = Enable(); + } + + protected override async Task Run() + { + var config = RuntimeConfigurationLoader.Load(_storagePath); + var connection = SeqConnectionFactory.Connect(_connection, config); + + var alert = await connection.Alerts.TemplateAsync(); + alert.OwnerId = null; + + alert.Title = _title; + alert.Description = _description; + alert.IsProtected = _isProtected; + alert.IsDisabled = _isDisabled; + + if (_from != null) + alert.DataSource = Enum.Parse(_from, ignoreCase: true); + + if (_signal != null) + alert.SignalExpression = SignalExpressionParser.ParseExpression(_signal); + + if (_lateral.Any()) + { + alert.Joins.Clear(); + foreach (var lateral in _lateral) + alert.Joins.Add(ParseLateral(lateral)); + } + + if (_where != null) + alert.Where = (await connection.Expressions.ToStrictAsync(_where)).StrictExpression; + + if (_select.Any()) + { + alert.Select.Clear(); + foreach (var measurement in _select) + alert.Select.Add(ParseColumn(measurement)); + } + + if (_groupBy.Any()) + { + alert.GroupBy.Clear(); + foreach (var grouping in _groupBy) + alert.GroupBy.Add(ParseGrouping(grouping)); + } + + if (_window != null) + alert.TimeGrouping = DurationMoniker.ToTimeSpan(_window); + + if (_having != null) + alert.Having = _having; + + if (_notificationLevel != null) + alert.NotificationLevel = Enum.Parse(LevelMapping.ToFullLevelName(_notificationLevel)); + + if (_suppressionTime != null) + alert.SuppressionTime = DurationMoniker.ToTimeSpan(_suppressionTime); + + foreach (var appInstanceId in _notificationApps) + alert.NotificationChannels.Add(new NotificationChannelPart { NotificationAppInstanceId = appInstanceId }); + + alert = await connection.Alerts.AddAsync(alert); + + _output.GetOutputFormat(config).WriteEntity(alert); + + return 0; + } + + static ColumnPart ParseColumn(string measurement) + { + var parsed = AliasedExpressionParser.ParseExpression(measurement); + return new ColumnPart { Value = parsed.Expression, Label = parsed.Alias }; + } + + static GroupingColumnPart ParseGrouping(string grouping) + { + var parsed = AliasedExpressionParser.ParseExpression(grouping, allowCaseInsensitive: true); + return new GroupingColumnPart + { + Value = parsed.Expression, + Label = parsed.Alias, + IsCaseInsensitive = parsed.IsCaseInsensitive + }; + } + + static JoinPart ParseLateral(string lateral) + { + var parsed = AliasedExpressionParser.ParseExpression(lateral); + return new JoinPart + { + Kind = JoinKind.Lateral, + SetFunctionCall = parsed.Expression, + Alias = parsed.Alias + }; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/DisableCommand.cs b/src/SeqCli/Cli/Commands/Alert/DisableCommand.cs new file mode 100644 index 00000000..3671e8c8 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/DisableCommand.cs @@ -0,0 +1,19 @@ +// 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. + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "disable", "Disable one or more alerts", + Example = "seqcli alert disable -t 'Too many errors'")] +class DisableCommand() : SetEnabledCommand(disable: true); diff --git a/src/SeqCli/Cli/Commands/Alert/EnableCommand.cs b/src/SeqCli/Cli/Commands/Alert/EnableCommand.cs new file mode 100644 index 00000000..90428733 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/EnableCommand.cs @@ -0,0 +1,19 @@ +// 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. + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "enable", "Enable one or more alerts", + Example = "seqcli alert enable -t 'Too many errors'")] +class EnableCommand() : SetEnabledCommand(disable: false); diff --git a/src/SeqCli/Cli/Commands/Alert/ListCommand.cs b/src/SeqCli/Cli/Commands/Alert/ListCommand.cs new file mode 100644 index 00000000..2f72ede8 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/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.Alert; + +[Command("alert", "list", "List alerts", Example="seqcli alert 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("alert", "list")); + _entityOwner = Enable(new EntityOwnerFeature("alert", "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.Alerts.FindAsync(_entityIdentity.Id)] + : + (await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(alert => _entityIdentity.Title == null || _entityIdentity.Title == alert.Title); + + _output.GetOutputFormat(config).ListEntities(list); + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/RemoveCommand.cs b/src/SeqCli/Cli/Commands/Alert/RemoveCommand.cs new file mode 100644 index 00000000..cd703220 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/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.Alert; + +[Command("alert", "remove", "Remove an alert from the server", + Example = "seqcli alert remove -t 'Too many errors'")] +class RemoveCommand : Command +{ + readonly EntityIdentityFeature _entityIdentity; + readonly ConnectionFeature _connection; + readonly EntityOwnerFeature _entityOwner; + readonly StoragePathFeature _storagePath; + + public RemoveCommand() + { + _entityIdentity = Enable(new EntityIdentityFeature("alert", "remove")); + _entityOwner = Enable(new EntityOwnerFeature("alert", "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.Alerts.FindAsync(_entityIdentity.Id)] + : + (await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(alert => _entityIdentity.Title == alert.Title) + .ToArray(); + + if (!toRemove.Any()) + { + Log.Error("No matching alert was found"); + return 1; + } + + foreach (var alert in toRemove) + await connection.Alerts.RemoveAsync(alert); + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/SetEnabledCommand.cs b/src/SeqCli/Cli/Commands/Alert/SetEnabledCommand.cs new file mode 100644 index 00000000..3c55a12c --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/SetEnabledCommand.cs @@ -0,0 +1,74 @@ +// 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.Alert; + +abstract class SetEnabledCommand : Command +{ + readonly bool _disable; + readonly EntityIdentityFeature _entityIdentity; + readonly EntityOwnerFeature _entityOwner; + readonly ConnectionFeature _connection; + readonly StoragePathFeature _storagePath; + + protected SetEnabledCommand(bool disable) + { + _disable = disable; + var verb = disable ? "disable" : "enable"; + var pastParticiple = disable ? "disabled" : "enabled"; + _entityIdentity = Enable(new EntityIdentityFeature("alert", verb)); + _entityOwner = Enable(new EntityOwnerFeature("alert", verb, pastParticiple, _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 toUpdate = _entityIdentity.Id != null ? [await connection.Alerts.FindAsync(_entityIdentity.Id)] + : + (await connection.Alerts.ListAsync(ownerId: _entityOwner.OwnerId, shared: _entityOwner.IncludeShared)) + .Where(alert => _entityIdentity.Title == alert.Title) + .ToArray(); + + if (!toUpdate.Any()) + { + Log.Error("No matching alert was found"); + return 1; + } + + foreach (var alert in toUpdate.Where(alert => alert.IsDisabled != _disable)) + { + alert.IsDisabled = _disable; + await connection.Alerts.UpdateAsync(alert); + } + + return 0; + } +} diff --git a/src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs b/src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs new file mode 100644 index 00000000..392ff3c3 --- /dev/null +++ b/src/SeqCli/Cli/Commands/Alert/UpdateCommand.cs @@ -0,0 +1,23 @@ +// 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 Seq.Api; + +namespace SeqCli.Cli.Commands.Alert; + +[Command("alert", "update", + "Update an existing alert", + Example="seqcli alert update --json '{...}'")] +class UpdateCommand(): + Shared.UpdateCommand("alert", nameof(SeqConnection.Alerts)); diff --git a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs index bbbd7f59..2b7c176b 100644 --- a/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs +++ b/src/SeqCli/Cli/Commands/Dashboard/RenderCommand.cs @@ -58,7 +58,7 @@ public RenderCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _connection = Enable(); } diff --git a/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs index f4da9047..f5495a24 100644 --- a/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs +++ b/src/SeqCli/Cli/Commands/Metrics/DimensionValuesCommand.cs @@ -47,7 +47,7 @@ public DimensionValuesCommand() v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); diff --git a/src/SeqCli/Cli/Commands/PrintCommand.cs b/src/SeqCli/Cli/Commands/PrintCommand.cs index 4eca68ec..505e1a10 100644 --- a/src/SeqCli/Cli/Commands/PrintCommand.cs +++ b/src/SeqCli/Cli/Commands/PrintCommand.cs @@ -20,11 +20,9 @@ using SeqCli.Cli.Features; using SeqCli.Config; using SeqCli.Ingestion; -using SeqCli.Output; using SeqCli.Util; using Serilog; using Serilog.Events; -using Serilog.Sinks.SystemConsole.Themes; namespace SeqCli.Cli.Commands; @@ -34,10 +32,10 @@ class PrintCommand : Command { readonly FileInputFeature _fileInputFeature; readonly InvalidDataHandlingFeature _invalidDataHandlingFeature; + readonly OutputFormatFeature _output; readonly StoragePathFeature _storage; - string? _filter, _template = OutputFormat.DefaultOutputTemplate; - bool? _noColor, _forceColor; + string? _filter, _template; public PrintCommand() { @@ -53,11 +51,7 @@ public PrintCommand() _invalidDataHandlingFeature = Enable(); - // These should be ported to use `OutputFormatFeature`. - Options.Add("no-color", "Don't colorize text output", _ => _noColor = true); - Options.Add("force-color", - "Force redirected output to have ANSI color (unless `--no-color` is also specified)", - _ => _forceColor = true); + _output = Enable(new OutputFormatFeature(supportNative: false, supportJson: false)); _storage = Enable(); } @@ -65,35 +59,21 @@ public PrintCommand() protected override async Task Run() { var config = RuntimeConfigurationLoader.Load(_storage); - - var applyThemeToRedirectedOutput - = !(_noColor ?? config.Output.DisableColor) && (_forceColor ?? config.Output.ForceColor); - - var theme - = _noColor ?? config.Output.DisableColor ? ConsoleTheme.None - : applyThemeToRedirectedOutput ? OutputFormat.DefaultAnsiTheme - : OutputFormat.DefaultTheme; - - var outputConfiguration = new LoggerConfiguration() - .MinimumLevel.Is(LevelAlias.Minimum) - .Enrich.With() - .WriteTo.Console( - outputTemplate: _template ?? OutputFormat.DefaultOutputTemplate, - theme: theme, - applyThemeToRedirectedOutput: applyThemeToRedirectedOutput); + Func? filter = null; if (_filter != null) { - if (!SerilogExpression.TryCompile(_filter, out var filter, out var error)) + if (!SerilogExpression.TryCompile(_filter, out var compiled, out var error)) { Log.Error("The specified filter could not be compiled: {Error}", error); return 1; } - - outputConfiguration.Filter.ByIncludingOnly(evt => ExpressionResult.IsTrue(filter(evt))); + + filter = evt => ExpressionResult.IsTrue(compiled(evt)); } - await using var logger = outputConfiguration.CreateLogger(); + var output = _output.GetOutputFormat(config, _template); + foreach (var input in _fileInputFeature.OpenInputs()) { using (input) @@ -108,8 +88,8 @@ var theme var result = await reader.TryReadAsync(); isAtEnd = result.IsAtEnd; - if (result.LogEvent != null) - logger.Write(result.LogEvent); + if (result.LogEvent != null && (filter == null || filter(result.LogEvent))) + output.WriteLogEvent(result.LogEvent); } catch (Exception ex) { diff --git a/src/SeqCli/Cli/Commands/QueryCommand.cs b/src/SeqCli/Cli/Commands/QueryCommand.cs index 6dd7db67..98004805 100644 --- a/src/SeqCli/Cli/Commands/QueryCommand.cs +++ b/src/SeqCli/Cli/Commands/QueryCommand.cs @@ -41,7 +41,7 @@ public QueryCommand() _range = Enable(); _signal = Enable(); _timeout = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); Options.Add("trace", "Enable detailed (server-side) query tracing", _ => _trace = true); _connection = Enable(); diff --git a/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs b/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs index caceca7c..462b3dc5 100644 --- a/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs +++ b/src/SeqCli/Cli/Commands/Sample/SetupCommand.cs @@ -82,7 +82,7 @@ internal static async Task ImportTemplates(SeqConnection connection) templates.Add(template); } - var err = await TemplateSetImporter.ImportAsync(templates, connection, templateArgs, new TemplateImportState(), merge: false); + var err = await TemplateSetImporter.ImportAsync(templates, connection, templateArgs, new TemplateImportState(), merge: true); if (err != null) { await Console.Error.WriteLineAsync(err); diff --git a/src/SeqCli/Cli/Commands/SearchCommand.cs b/src/SeqCli/Cli/Commands/SearchCommand.cs index a9b1e049..365725b7 100644 --- a/src/SeqCli/Cli/Commands/SearchCommand.cs +++ b/src/SeqCli/Cli/Commands/SearchCommand.cs @@ -50,7 +50,7 @@ public SearchCommand() v => _count = int.Parse(v, CultureInfo.InvariantCulture)); _range = Enable(); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _signal = Enable(); diff --git a/src/SeqCli/Cli/Commands/TailCommand.cs b/src/SeqCli/Cli/Commands/TailCommand.cs index 3da0b991..9d4d4957 100644 --- a/src/SeqCli/Cli/Commands/TailCommand.cs +++ b/src/SeqCli/Cli/Commands/TailCommand.cs @@ -38,7 +38,7 @@ public TailCommand() "An optional server-side filter to apply to the stream, for example `@Level = 'Error'`", v => _filter = v); - _output = Enable(new OutputFormatFeature(supportNative: true)); + _output = Enable(new OutputFormatFeature(supportNative: true, supportJson: true)); _storagePath = Enable(); _signal = Enable(); _connection = Enable(); diff --git a/src/SeqCli/Cli/Features/OutputFormatFeature.cs b/src/SeqCli/Cli/Features/OutputFormatFeature.cs index fde2b7b8..58817640 100644 --- a/src/SeqCli/Cli/Features/OutputFormatFeature.cs +++ b/src/SeqCli/Cli/Features/OutputFormatFeature.cs @@ -17,30 +17,30 @@ namespace SeqCli.Cli.Features; -class OutputFormatFeature(bool supportNative) : CommandFeature +class OutputFormatFeature(bool supportNative, bool supportJson) : CommandFeature { OutputSyntax _syntax = OutputSyntax.Text; bool? _noColor, _forceColor; - + // ReSharper disable once UnusedMember.Global public OutputFormatFeature() - : this(false) { } + : this(supportNative: false, supportJson: true) { } - public OutputFormat GetOutputFormat(SeqCliConfig config) + public OutputFormat GetOutputFormat(SeqCliConfig config, string? outputTemplate = null) { - return new OutputFormat( - _syntax, - _noColor ?? config.Output.DisableColor, - _forceColor ?? config.Output.ForceColor); + return new OutputFormat(_syntax, _noColor, _forceColor, config.Output, outputTemplate); } public override void Enable(OptionSet options) { - options.Add( - "json", - "Print output in newline-delimited JSON (the default is plain text)", - _ => _syntax = OutputSyntax.Json); - + if (supportJson) + { + options.Add( + "json", + "Print output in newline-delimited JSON (the default is plain text)", + _ => _syntax = OutputSyntax.Json); + } + if (supportNative) { options.Add( diff --git a/src/SeqCli/Csv/CsvWriter.cs b/src/SeqCli/Csv/CsvWriter.cs index fff15271..75f6553a 100644 --- a/src/SeqCli/Csv/CsvWriter.cs +++ b/src/SeqCli/Csv/CsvWriter.cs @@ -1,20 +1,22 @@ using System; +using System.Collections.Generic; using System.IO; using Seq.Api.Model.Data; using SeqCli.Mcp.Data; -using Serilog.Sinks.SystemConsole.Themes; +using SeqCli.Output; +using Serilog.Templates.Themes; namespace SeqCli.Csv; static class CsvWriter { - public static void WriteQueryResult(QueryResultPart result, Func stringify, ConsoleTheme theme, TextWriter output) + public static void WriteQueryResult(QueryResultPart result, Func stringify, TemplateTheme? theme, TextWriter output) { if (!string.IsNullOrWhiteSpace(result.Error)) { - theme.Set(output, ConsoleThemeStyle.Text); + theme?.Set(output, TemplateThemeStyle.Text); QueryResultHelper.WriteErrorResult(output, result); - theme.Reset(output); + theme?.Reset(output); } var first = true; @@ -30,7 +32,7 @@ public static void WriteQueryResult(QueryResultPart result, Func stringify, ref bool firstCol, bool isHeadingRow = false) + static void WriteCell(TextWriter output, TemplateTheme? theme, object? value, Func stringify, ref bool firstCol, bool isHeadingRow = false) { if (firstCol) { @@ -38,39 +40,39 @@ static void WriteCell(TextWriter output, ConsoleTheme theme, object? value, Func } else { - theme.Set(output, ConsoleThemeStyle.TertiaryText); + theme?.Set(output, TemplateThemeStyle.TertiaryText); output.Write(','); - theme.Reset(output); + theme?.Reset(output); } - theme.Set(output, ConsoleThemeStyle.TertiaryText); + theme?.Set(output, TemplateThemeStyle.TertiaryText); output.Write('"'); - theme.Reset(output); + theme?.Reset(output); var valueAsString = stringify(value); - var dataStyle = isHeadingRow ? ConsoleThemeStyle.Name : ConsoleThemeStyle.Text; + var dataStyle = isHeadingRow ? TemplateThemeStyle.Name : TemplateThemeStyle.Text; var doubleQuote = valueAsString.IndexOf('"'); while (doubleQuote != -1) { - theme.Set(output, dataStyle); + theme?.Set(output, dataStyle); output.Write(valueAsString[..doubleQuote]); - theme.Reset(output); + theme?.Reset(output); - theme.Set(output, ConsoleThemeStyle.Scalar); + theme?.Set(output, TemplateThemeStyle.Scalar); output.Write("\"\""); - theme.Reset(output); + theme?.Reset(output); valueAsString = valueAsString[(doubleQuote + 1)..]; doubleQuote = valueAsString.IndexOf('"'); } - theme.Set(output, dataStyle); + theme?.Set(output, dataStyle); output.Write(valueAsString); - theme.Reset(output); + theme?.Reset(output); - theme.Set(output, ConsoleThemeStyle.TertiaryText); + theme?.Set(output, TemplateThemeStyle.TertiaryText); output.Write('"'); - theme.Reset(output); + theme?.Reset(output); } } \ No newline at end of file diff --git a/src/SeqCli/Forwarder/ForwarderModule.cs b/src/SeqCli/Forwarder/ForwarderModule.cs index 4f97cb46..6bb7ef67 100644 --- a/src/SeqCli/Forwarder/ForwarderModule.cs +++ b/src/SeqCli/Forwarder/ForwarderModule.cs @@ -68,13 +68,13 @@ protected override void Load(ContainerBuilder builder) Log.ForContext().Warning("Configured to expose ingestion log via HTTP API"); builder.RegisterType().As(); - var ingestionLogTemplate = "[{@t:o} {@l:u3}] {@m}\n"; + var ingestionLogTemplate = $"[{{@t:o}} {{@l:u3}}] {{@m}}{Environment.NewLine}"; if (_config.Forwarder.Diagnostics.IngestionLogShowDetail) { Log.ForContext().Warning("Including full client, payload, and error detail in the ingestion log"); ingestionLogTemplate += - "{#if ClientHostIP is not null}Client IP address: {ClientHostIP}\n{#end}" + - "{#if DocumentStart is not null}First {StartToLog} characters of payload: {DocumentStart:l}\n{#end}" + + $"{{#if ClientHostIP is not null}}Client IP address: {{ClientHostIP}}{Environment.NewLine}{{#end}}" + + $"{{#if DocumentStart is not null}}First {{StartToLog}} characters of payload: {{DocumentStart:l}}{Environment.NewLine}{{#end}}" + "{@x}"; } diff --git a/src/SeqCli/Ingestion/LogShipper.cs b/src/SeqCli/Ingestion/LogShipper.cs index c5738604..f0a19741 100644 --- a/src/SeqCli/Ingestion/LogShipper.cs +++ b/src/SeqCli/Ingestion/LogShipper.cs @@ -33,7 +33,7 @@ namespace SeqCli.Ingestion; static class LogShipper { - static readonly ITextFormatter JsonFormatter = OutputFormatter.Json(null); + static readonly ITextFormatter JsonFormatter = TextFormatters.Json(null); public static async Task ShipBufferAsync( SeqConnection connection, diff --git a/src/SeqCli/Mcp/McpServerInstaller.cs b/src/SeqCli/Mcp/McpServerInstaller.cs index 52bc4596..1160aa33 100644 --- a/src/SeqCli/Mcp/McpServerInstaller.cs +++ b/src/SeqCli/Mcp/McpServerInstaller.cs @@ -87,13 +87,13 @@ static class McpServerInstaller "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\"]"), + $"Codex reads MCP servers from ~/.codex/config.toml (TOML), which seqcli can't edit automatically. Add this block:{Environment.NewLine}{Environment.NewLine}[mcp_servers.seq]{Environment.NewLine}command = \"seqcli\"{Environment.NewLine}args = [\"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"), + $"Goose reads MCP servers from ~/.config/goose/config.yaml (YAML) under `extensions`, which seqcli can't edit automatically. Add:{Environment.NewLine}{Environment.NewLine}extensions:{Environment.NewLine} seq:{Environment.NewLine} type: stdio{Environment.NewLine} cmd: seqcli{Environment.NewLine} args: [mcp, run]{Environment.NewLine} 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"), + $"Continue reads MCP servers from YAML, which seqcli can't edit automatically. Create .continue/mcpServers/seq.yaml with:{Environment.NewLine}{Environment.NewLine}name: Seq{Environment.NewLine}version: 0.0.1{Environment.NewLine}schema: v1{Environment.NewLine}mcpServers:{Environment.NewLine} - name: seq{Environment.NewLine} command: seqcli{Environment.NewLine} args:{Environment.NewLine} - mcp{Environment.NewLine} - run"), }; static readonly IReadOnlyDictionary AgentAliases = diff --git a/src/SeqCli/Mcp/Tools/Search/SearchTools.cs b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs index 42343c8a..72663a49 100644 --- a/src/SeqCli/Mcp/Tools/Search/SearchTools.cs +++ b/src/SeqCli/Mcp/Tools/Search/SearchTools.cs @@ -43,7 +43,7 @@ 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}}" + $"{{{ResultIdPropertyName}}} [{{UtcDateTime(@t)}} {{{LevelMapping.SurrogateLevelProperty}}}] {{@m}}{Environment.NewLine}{{#if @x is not null}}{{Substring(ToString(@x), 0, 512)}}...{Environment.NewLine}{{#end}}" ); [McpServerTool(Name = "seq_new_session", ReadOnly = true, Title = "Begin a new Search/Query Session")] diff --git a/src/SeqCli/Output/FlareTheme.cs b/src/SeqCli/Output/FlareTheme.cs new file mode 100644 index 00000000..38d1e578 --- /dev/null +++ b/src/SeqCli/Output/FlareTheme.cs @@ -0,0 +1,70 @@ +// 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.Collections.Generic; +using System.IO; +using Serilog.Templates.Themes; + +namespace SeqCli.Output; + +/// +/// Flare is Seq's embedded stream/columnar database. This theme is derived from one build originally +/// for the flaretl command-line tooling used there. +/// +static class FlareTheme +{ + static readonly Dictionary FlareThemeStyles = new() + { + [TemplateThemeStyle.Name] = "\e[38;5;0215m", + [TemplateThemeStyle.Number] = "\e[38;5;0200m", + [TemplateThemeStyle.Boolean] = "\e[38;5;0039m", + [TemplateThemeStyle.Null] = "\e[38;5;0039m", + [TemplateThemeStyle.String] = "\e[38;5;0217m", + [TemplateThemeStyle.Scalar] = "\e[38;5;0217m", + [TemplateThemeStyle.LevelError] = "\e[38;5;0197m", + [TemplateThemeStyle.TertiaryText] = "\e[38;5;0244m", + // Based on `TemplateTheme.Code` + [TemplateThemeStyle.Text] = "\e[38;5;0253m", + [TemplateThemeStyle.SecondaryText] = "\e[38;5;0246m", + [TemplateThemeStyle.Invalid] = "\e[33;1m", + [TemplateThemeStyle.LevelVerbose] = "\e[37m", + [TemplateThemeStyle.LevelDebug] = "\e[37m", + [TemplateThemeStyle.LevelInformation] = "\e[37;1m", + [TemplateThemeStyle.LevelWarning] = "\e[38;5;0229m", + [TemplateThemeStyle.LevelFatal] = "\e[38;5;0197m\e[48;5;0238m" + }; + + public static readonly TemplateTheme SeqCli = new(FlareThemeStyles); + + // `CsvWriter` implements its own theming behavior because the required APIs are not public in Serilog.Expressions. + // The best way forward for this is likely to be porting theming to Seq.Syntax, and exposing the required APIs there. + + const string AnsiStyleResetSequence = "\e[0m"; + + // The passed-in theme is ignored because SerilogExpressions themes are opaque. All formatting uses the SeqCli theme. + // ReSharper disable once UnusedParameter.Global + extension(TemplateTheme theme) + { + public void Set(TextWriter output, TemplateThemeStyle style) + { + if (FlareThemeStyles.TryGetValue(style, out var styleSequence)) + output.Write(styleSequence); + } + + public void Reset(TextWriter output) + { + output.Write(AnsiStyleResetSequence); + } + } +} \ No newline at end of file diff --git a/src/SeqCli/Output/OutputFormat.cs b/src/SeqCli/Output/OutputFormat.cs index affa48ef..e84b7026 100644 --- a/src/SeqCli/Output/OutputFormat.cs +++ b/src/SeqCli/Output/OutputFormat.cs @@ -24,6 +24,7 @@ using Seq.Api.Model; using Seq.Api.Model.Data; using Seq.Api.Model.Events; +using SeqCli.Config; using SeqCli.Csv; using SeqCli.Mapping; using SeqCli.Util; @@ -31,16 +32,17 @@ using Serilog.Core; using Serilog.Events; using Serilog.Parsing; -using Serilog.Sinks.SystemConsole.Themes; using Serilog.Templates.Themes; namespace SeqCli.Output; sealed class OutputFormat { + // See https://no-color.org for semantics. + const string NoColorEnvironmentVariable = "NO_COLOR"; + readonly OutputSyntax _syntax; - readonly bool _noColor; - readonly bool _forceColor; + readonly string? _outputTemplate; readonly Logger _formatter; readonly JsonSerializer _serializer = JsonSerializer.CreateDefault(new JsonSerializerSettings @@ -52,39 +54,84 @@ sealed class OutputFormat } }); - public const string DefaultOutputTemplate = - "[{Timestamp:o} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"; + public OutputFormat( + OutputSyntax syntax, + bool? noColor, + bool? forceColor, + SeqCliOutputConfig outputConfig, + string? outputTemplate = null) + : this( + syntax, + noColor, + forceColor, + outputConfig, + outputTemplate, + noColorSetInEnvironment: NoColorSetInEnvironment(), + outputIsRedirected: Console.IsOutputRedirected, + allowAnsiEscapes: TerminalFeatures.TryEnableAnsiEscapes()) + { + } - public static readonly ConsoleTheme DefaultAnsiTheme = AnsiConsoleTheme.Code; + /// The syntax to write output in. + /// The value of --no-color, if specified. + /// The value of --force-color, if specified. + /// Configured output defaults. + /// The template controlling plain-text formatting, or null for the default. + /// Whether NO_COLOR is set; see . + /// Whether STDOUT is redirected, i.e. not attached to a terminal. + /// Whether ANSI escape sequences are allowed; generally false for interactive + /// legacy Windows terminals and true otherwise. + internal OutputFormat( + OutputSyntax syntax, + bool? noColor, + bool? forceColor, + SeqCliOutputConfig outputConfig, + string? outputTemplate, + bool noColorSetInEnvironment, + bool outputIsRedirected, + bool allowAnsiEscapes) + { + _syntax = syntax; + _outputTemplate = outputTemplate; - public static readonly ConsoleTheme DefaultTheme = - OperatingSystem.IsWindows() ? SystemConsoleTheme.Literate : DefaultAnsiTheme; + var resolvedNoColor = ResolveNoColor(noColor, forceColor, outputConfig, noColorSetInEnvironment, allowAnsiEscapes); + var applyThemeToRedirectedOutput = !resolvedNoColor && (forceColor ?? outputConfig.ForceColor); + var colorize = !resolvedNoColor && (applyThemeToRedirectedOutput || !outputIsRedirected); - static readonly TemplateTheme DefaultTemplateTheme = TemplateTheme.Code; + TemplateTheme = colorize + ? FlareTheme.SeqCli + : null; - public OutputFormat(OutputSyntax syntax, bool noColor, bool forceColor) - { - _syntax = syntax; - _noColor = noColor; - _forceColor = forceColor; _formatter = CreateOutputLogger(); } + static bool NoColorSetInEnvironment() + => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(NoColorEnvironmentVariable)); + + internal static bool ResolveNoColor( + bool? noColorFlag, + bool? forceColorFlag, + SeqCliOutputConfig config, + bool noColorSetInEnvironment, + bool supportsAnsiEscapes) + { + if (!supportsAnsiEscapes) + return true; + + if (noColorFlag != null) + return noColorFlag.Value; + + if (config.DisableColor) + return true; + + return forceColorFlag != true && noColorSetInEnvironment; + } + 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; + internal TemplateTheme? TemplateTheme { get; } public bool RequiresRender => Native; @@ -96,14 +143,11 @@ Logger CreateOutputLogger() if (Json) { - outputConfiguration.WriteTo.Console(OutputFormatter.Json(TemplateTheme)); + outputConfiguration.WriteTo.Console(TextFormatters.Json(TemplateTheme)); } else if (Text) { - outputConfiguration.WriteTo.Console( - outputTemplate: DefaultOutputTemplate, - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput); + outputConfiguration.WriteTo.Console(TextFormatters.Plain(TemplateTheme, _outputTemplate)); } // The logger is not configured for Native output, which avoids it. Ideally we'll shift away from using @@ -127,10 +171,7 @@ public void WriteEntity(Entity entity) var writer = new LoggerConfiguration() .Destructure.With() .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .WriteTo.Console(TextFormatters.Plain(TemplateTheme, "{@m}" + Environment.NewLine)) .CreateLogger(); writer.Information("{@Entity}", jo); } @@ -160,10 +201,7 @@ public void WriteObject(object value) var writer = new LoggerConfiguration() .Destructure.With() .Enrich.With() - .WriteTo.Console( - outputTemplate: "{@Message:j}{NewLine}", - theme: Theme, - applyThemeToRedirectedOutput: ApplyThemeToRedirectedOutput) + .WriteTo.Console(TextFormatters.Plain(TemplateTheme, "{@m}" + Environment.NewLine)) .CreateLogger(); writer.Information("{@Entity}", jo); } @@ -206,7 +244,7 @@ public void WriteQueryResult(QueryResultPart result) } else { - CsvWriter.WriteQueryResult(result, Stringify, Theme, Console.Out); + CsvWriter.WriteQueryResult(result, Stringify, TemplateTheme, Console.Out); } } @@ -219,10 +257,15 @@ public void WriteEventEntity(EventEntity evt) } else { - _formatter.Write(ToSerilogEvent(evt)); + WriteLogEvent(ToSerilogEvent(evt)); } } + public void WriteLogEvent(LogEvent logEvent) + { + _formatter.Write(logEvent); + } + public static LogEvent ToSerilogEvent(EventEntity evt) { ActivityTraceId traceId = default; diff --git a/src/SeqCli/Output/OutputFormatter.cs b/src/SeqCli/Output/OutputFormatter.cs deleted file mode 100644 index 43ff994b..00000000 --- a/src/SeqCli/Output/OutputFormatter.cs +++ /dev/null @@ -1,25 +0,0 @@ -using SeqCli.Ingestion; -using SeqCli.Mapping; -using Serilog.Formatting; -using Serilog.Templates; -using Serilog.Templates.Themes; - -namespace SeqCli.Output; - -static class OutputFormatter -{ - // This is the only usage of Serilog.Expressions remaining in seqcli; the upstream Seq.Syntax doesn't yet support - // 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( - $"{{ " + - $"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/TerminalFeatures.cs b/src/SeqCli/Output/TerminalFeatures.cs new file mode 100644 index 00000000..7262de8a --- /dev/null +++ b/src/SeqCli/Output/TerminalFeatures.cs @@ -0,0 +1,52 @@ +// 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.Runtime.InteropServices; + +namespace SeqCli.Output; + +static class TerminalFeatures +{ + const int StandardOutputHandleId = -11; + const uint EnableVirtualTerminalProcessingMode = 4; + const long InvalidHandleValue = -1; + + public static bool TryEnableAnsiEscapes() + { + if (Console.IsOutputRedirected || !OperatingSystem.IsWindows()) + return true; + + var stdout = GetStdHandle(StandardOutputHandleId); + if (stdout == InvalidHandleValue) + return false; + + if (!GetConsoleMode(stdout, out var mode)) + return false; + + if ((mode & EnableVirtualTerminalProcessingMode) != 0) + return true; + + return SetConsoleMode(stdout, mode | EnableVirtualTerminalProcessingMode); + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int handleId); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetConsoleMode(IntPtr handle, out uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetConsoleMode(IntPtr handle, uint mode); +} diff --git a/src/SeqCli/Output/TextFormatters.cs b/src/SeqCli/Output/TextFormatters.cs new file mode 100644 index 00000000..86cfbee7 --- /dev/null +++ b/src/SeqCli/Output/TextFormatters.cs @@ -0,0 +1,54 @@ +// 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 SeqCli.Ingestion; +using SeqCli.Mapping; +using Serilog.Expressions; +using Serilog.Formatting; +using Serilog.Templates; +using Serilog.Templates.Themes; + +namespace SeqCli.Output; + +// This is the only usage of Serilog.Expressions remaining in seqcli; the upstream Seq.Syntax doesn't yet support +// tracing properties or theming. +static class TextFormatters +{ + public static ITextFormatter Json(TemplateTheme? theme) => new ExpressionTemplate( + $"{{ " + + $"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()}} " + + $"}}" + + Environment.NewLine, + theme: theme, + // The `OutputFormat` constructor has already decided whether to colorize. + applyThemeWhenOutputIsRedirected: true + ); + + static readonly string DefaultPlainTextOutputTemplate = + "[{@t:o} {@l:u3}] {@m}{#if IsSpan()} ({Milliseconds(Elapsed()):0.###} ms){#end}" + Environment.NewLine + "{@x}"; + + public static ITextFormatter Plain(TemplateTheme? theme, string? outputTemplate) => new ExpressionTemplate( + outputTemplate ?? DefaultPlainTextOutputTemplate, + theme: theme, + nameResolver: new StaticMemberNameResolver(typeof(TracingFunctions)), + // The `OutputFormat` constructor has already decided whether to colorize. + applyThemeWhenOutputIsRedirected: true + ); +} \ No newline at end of file diff --git a/src/SeqCli/Output/TracingFunctions.cs b/src/SeqCli/Output/TracingFunctions.cs new file mode 100644 index 00000000..5e2ba112 --- /dev/null +++ b/src/SeqCli/Output/TracingFunctions.cs @@ -0,0 +1,55 @@ +// 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 SeqCli.Ingestion; +using Serilog.Events; + +namespace SeqCli.Output; + +static class TracingFunctions +{ + public static LogEventPropertyValue? Elapsed(LogEvent logEvent) + { + if (logEvent.Properties.TryGetValue(TraceConstants.SpanStartTimestampProperty, out var sst) && + sst is ScalarValue { Value: DateTime spanStart }) + { + return new ScalarValue(logEvent.Timestamp - spanStart); + } + + if (logEvent.Properties.TryGetValue("@st", out var st) && + st is ScalarValue { Value: string spanStartIso } && + DateTimeOffset.TryParse(spanStartIso, CultureInfo.InvariantCulture, out var spanStartDto)) + { + return new ScalarValue(logEvent.Timestamp - spanStartDto); + } + + return null; + } + + public static LogEventPropertyValue? IsSpan(LogEvent logEvent) + { + return new ScalarValue(Elapsed(logEvent) != null); + } + + public static LogEventPropertyValue? Milliseconds(LogEventPropertyValue? timeSpan) + { + // Truncates instead of rounding. + if (timeSpan is ScalarValue { Value: TimeSpan ts }) + return new ScalarValue((decimal)ts.Ticks / TimeSpan.TicksPerMillisecond); + + return null; + } +} \ No newline at end of file diff --git a/src/SeqCli/Program.cs b/src/SeqCli/Program.cs index 5363b98f..f6a0a11f 100644 --- a/src/SeqCli/Program.cs +++ b/src/SeqCli/Program.cs @@ -26,8 +26,6 @@ namespace SeqCli; class Program { - public const string WindowsBinaryName = "seqcli.exe"; - static async Task Main(string[] args) { var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Error); diff --git a/src/SeqCli/Sample/Templates/alert-Batch Processing Errors.template b/src/SeqCli/Sample/Templates/alert-Batch Processing Errors.template new file mode 100644 index 00000000..dfcc6069 --- /dev/null +++ b/src/SeqCli/Sample/Templates/alert-Batch Processing Errors.template @@ -0,0 +1,35 @@ +{ + "$entity": "alert", + "Title": "Batch Processing Errors", + "Description": "Created by `seqcli sample setup`", + "OwnerId": null, + "IsProtected": false, + "IsDisabled": false, + "DataSource": "Stream", + "Joins": [], + "SignalExpression": { + "Kind": "Intersection", + "Left": { + "Kind": "Signal", + "SignalId": ref("signal-Errors.template") + }, + "Right": { + "Kind": "Signal", + "SignalId": ref("signal-Batch Processing.template") + } + }, + "Where": null, + "GroupBy": [], + "TimeGrouping": "00:01:00", + "Select": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Having": "count > 0", + "NotificationLevel": "Error", + "NotificationProperties": [], + "NotificationChannels": [], + "SuppressionTime": "00:10:00" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/alert-Frontend Errors.template b/src/SeqCli/Sample/Templates/alert-Frontend Errors.template new file mode 100644 index 00000000..f7fbb61b --- /dev/null +++ b/src/SeqCli/Sample/Templates/alert-Frontend Errors.template @@ -0,0 +1,35 @@ +{ + "$entity": "alert", + "Title": "Frontend Errors", + "Description": "Created by `seqcli sample setup`", + "OwnerId": null, + "IsProtected": false, + "IsDisabled": false, + "DataSource": "Stream", + "Joins": [], + "SignalExpression": { + "Kind": "Intersection", + "Left": { + "Kind": "Signal", + "SignalId": ref("signal-Errors.template") + }, + "Right": { + "Kind": "Signal", + "SignalId": ref("signal-Web Frontend.template") + } + }, + "Where": null, + "GroupBy": [], + "TimeGrouping": "00:01:00", + "Select": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Having": "count > 0", + "NotificationLevel": "Error", + "NotificationProperties": [], + "NotificationChannels": [], + "SuppressionTime": "00:10:00" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/alert-Low Stock.template b/src/SeqCli/Sample/Templates/alert-Low Stock.template new file mode 100644 index 00000000..87d039d3 --- /dev/null +++ b/src/SeqCli/Sample/Templates/alert-Low Stock.template @@ -0,0 +1,25 @@ +{ + "$entity": "alert", + "Title": "Low Stock", + "Description": "Created by `seqcli sample setup`", + "OwnerId": null, + "IsProtected": false, + "IsDisabled": false, + "DataSource": "Stream", + "Joins": [], + "SignalExpression": null, + "Where": "@EventType = 0x725f0870", + "GroupBy": [], + "TimeGrouping": "00:01:00", + "Select": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Having": "count > 0", + "NotificationLevel": "Error", + "NotificationProperties": [], + "NotificationChannels": [], + "SuppressionTime": "00:10:00" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/alert-Production Errors.template b/src/SeqCli/Sample/Templates/alert-Production Errors.template new file mode 100644 index 00000000..6baa964c --- /dev/null +++ b/src/SeqCli/Sample/Templates/alert-Production Errors.template @@ -0,0 +1,35 @@ +{ + "$entity": "alert", + "Title": "Production Errors", + "Description": "Created by `seqcli sample setup`", + "OwnerId": null, + "IsProtected": false, + "IsDisabled": false, + "DataSource": "Stream", + "Joins": [], + "SignalExpression": { + "Kind": "Intersection", + "Left": { + "Kind": "Signal", + "SignalId": ref("signal-Errors.template") + }, + "Right": { + "Kind": "Signal", + "SignalId": ref("signal-Roastery Production.template") + } + }, + "Where": null, + "GroupBy": [], + "TimeGrouping": "00:01:00", + "Select": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Having": "count > 0", + "NotificationLevel": "Error", + "NotificationProperties": [], + "NotificationChannels": [], + "SuppressionTime": "00:10:00" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/dashboard-Database.template b/src/SeqCli/Sample/Templates/dashboard-Database.template index f3d711f0..7b0c00e1 100644 --- a/src/SeqCli/Sample/Templates/dashboard-Database.template +++ b/src/SeqCli/Sample/Templates/dashboard-Database.template @@ -1,21 +1,23 @@ { "$entity": "dashboard", - "OwnerId": arg("ownerId"), + "OwnerId": null, "Title": "Database", "IsProtected": false, "SignalExpression": { - "SignalId": ref("signal-Sample Data.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Sample Data.template") }, "Charts": [ { "Title": "Database Time by Action (ms)", "SignalExpression": { - "SignalId": ref("signal-Database.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Database.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "sum(Elapsed)", @@ -32,10 +34,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -44,16 +46,19 @@ "DisplayStyle": { "WidthColumns": 6, "HeightRows": 2 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Slow Queries", "SignalExpression": { - "SignalId": ref("signal-Database.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Database.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "Elapsed", @@ -72,10 +77,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [ "elapsed_ms desc" @@ -86,16 +91,19 @@ "DisplayStyle": { "WidthColumns": 6, "HeightRows": 2 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Statement Execution Time (ms)", "SignalExpression": { - "SignalId": ref("signal-Database.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Database.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "max(Elapsed)", @@ -118,10 +126,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "OrangePurple" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -130,7 +138,8 @@ "DisplayStyle": { "WidthColumns": 12, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" } ] } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/dashboard-HTTP Requests.template b/src/SeqCli/Sample/Templates/dashboard-HTTP Requests.template index 5f3fc05d..0b5de04d 100644 --- a/src/SeqCli/Sample/Templates/dashboard-HTTP Requests.template +++ b/src/SeqCli/Sample/Templates/dashboard-HTTP Requests.template @@ -1,21 +1,23 @@ { "$entity": "dashboard", - "OwnerId": arg("ownerId"), + "OwnerId": null, "Title": "HTTP Requests", "IsProtected": false, "SignalExpression": { - "SignalId": ref("signal-Sample Data.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Sample Data.template") }, "Charts": [ { "Title": "Requests by Status Code", "SignalExpression": { - "SignalId": ref("signal-HTTP Requests.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-HTTP Requests.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(*)", @@ -32,10 +34,10 @@ "LineFillToZeroY": true, "LineShowMarkers": false, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -44,16 +46,19 @@ "DisplayStyle": { "WidthColumns": 12, "HeightRows": 2 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "99th Percentile Response Time", "SignalExpression": { - "SignalId": ref("signal-HTTP Requests.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-HTTP Requests.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "Concat(ToJson(Round(percentile(Elapsed, 99), 0)), ' ms')", @@ -68,10 +73,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -80,16 +85,19 @@ "DisplayStyle": { "WidthColumns": 4, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Mean Response Time", "SignalExpression": { - "SignalId": ref("signal-HTTP Requests.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-HTTP Requests.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "Concat(ToJson(Round(mean(Elapsed), 0)), ' ms')", @@ -104,10 +112,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -116,23 +124,26 @@ "DisplayStyle": { "WidthColumns": 4, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Internal Server Errors", "SignalExpression": { "Kind": "Intersection", "Left": { - "SignalId": ref("signal-HTTP Requests.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-HTTP Requests.template") }, "Right": { - "SignalId": ref("signal-Internal Server Error.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Internal Server Error.template") } }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(*)", @@ -147,10 +158,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": true, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Reds" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -159,7 +170,8 @@ "DisplayStyle": { "WidthColumns": 4, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" } ] } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/dashboard-Orders.template b/src/SeqCli/Sample/Templates/dashboard-Orders.template index a0aed29d..835f9407 100644 --- a/src/SeqCli/Sample/Templates/dashboard-Orders.template +++ b/src/SeqCli/Sample/Templates/dashboard-Orders.template @@ -1,11 +1,11 @@ { "$entity": "dashboard", - "OwnerId": arg("ownerId"), + "OwnerId": null, "Title": "Orders", "IsProtected": false, "SignalExpression": { - "SignalId": ref("signal-Sample Data.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Sample Data.template") }, "Charts": [ { @@ -13,6 +13,8 @@ "SignalExpression": null, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(@EventType = 0x8CC54029)", @@ -39,10 +41,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -51,13 +53,16 @@ "DisplayStyle": { "WidthColumns": 8, "HeightRows": 2 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Product Volume", "SignalExpression": null, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "round(sum(Product.SizeInGrams) / 1000, 1)", @@ -74,10 +79,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Default" }, - "Alerts": [], "Having": null, "OrderBy": [ "volume_kg desc" @@ -88,16 +93,19 @@ "DisplayStyle": { "WidthColumns": 4, "HeightRows": 2 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Created", "SignalExpression": { - "SignalId": ref("signal-Order Created.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Order Created.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(*)", @@ -112,10 +120,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Blues" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -124,16 +132,19 @@ "DisplayStyle": { "WidthColumns": 3, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Placed", "SignalExpression": { - "SignalId": ref("signal-Order Placed.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Order Placed.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(*)", @@ -148,10 +159,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "OrangePurple" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -160,16 +171,19 @@ "DisplayStyle": { "WidthColumns": 3, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Shipped", "SignalExpression": { - "SignalId": ref("signal-Order Shipped.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Order Shipped.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(*)", @@ -184,10 +198,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Greens" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -196,16 +210,19 @@ "DisplayStyle": { "WidthColumns": 3, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" }, { "Title": "Abandoned", "SignalExpression": { - "SignalId": ref("signal-Order Abandoned.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Order Abandoned.template") }, "Queries": [ { + "DataSource": "Stream", + "Joins": [], "Measurements": [ { "Value": "count(*)", @@ -220,10 +237,10 @@ "LineFillToZeroY": false, "LineShowMarkers": true, "BarOverlaySum": false, + "UseLogarithmicScale": false, "SuppressLegend": false, "Palette": "Reds" }, - "Alerts": [], "Having": null, "OrderBy": [], "Limit": null @@ -232,7 +249,8 @@ "DisplayStyle": { "WidthColumns": 3, "HeightRows": 1 - } + }, + "Description": "Created by `seqcli sample setup`" } ] } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/dashboard-Overview.template b/src/SeqCli/Sample/Templates/dashboard-Overview.template new file mode 100644 index 00000000..c644f96f --- /dev/null +++ b/src/SeqCli/Sample/Templates/dashboard-Overview.template @@ -0,0 +1,155 @@ +{ + "$entity": "dashboard", + "OwnerId": null, + "Title": "Overview", + "IsProtected": false, + "SignalExpression": null, + "Charts": [ + { + "Title": "All Events", + "SignalExpression": null, + "Queries": [ + { + "DataSource": "Stream", + "Joins": [], + "Measurements": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Where": null, + "SignalExpression": null, + "GroupBy": [], + "DisplayStyle": { + "Type": "Line", + "LineFillToZeroY": true, + "LineShowMarkers": false, + "BarOverlaySum": false, + "UseLogarithmicScale": false, + "SuppressLegend": false, + "Palette": "Default" + }, + "Having": null, + "OrderBy": [], + "Limit": null + } + ], + "DisplayStyle": { + "WidthColumns": 8, + "HeightRows": 1 + }, + "Description": "Created by `seqcli sample setup`" + }, + { + "Title": "Count by Level", + "SignalExpression": null, + "Queries": [ + { + "DataSource": "Stream", + "Joins": [], + "Measurements": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Where": null, + "SignalExpression": null, + "GroupBy": [ + "@Level" + ], + "DisplayStyle": { + "Type": "Pie", + "LineFillToZeroY": false, + "LineShowMarkers": true, + "BarOverlaySum": false, + "UseLogarithmicScale": false, + "SuppressLegend": false, + "Palette": "Default" + }, + "Having": null, + "OrderBy": [], + "Limit": null + } + ], + "DisplayStyle": { + "WidthColumns": 4, + "HeightRows": 1 + }, + "Description": "Created by `seqcli sample setup`" + }, + { + "Title": "Errors and Exceptions", + "SignalExpression": null, + "Queries": [ + { + "DataSource": "Stream", + "Joins": [], + "Measurements": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Where": "@Exception is not null or @Level in ['f', 'fa', 'fat', 'ftl', 'fata', 'fatl', 'fatal', 'c', 'cr', 'cri', 'crt', 'crit', 'critical', 'alert', 'emerg', 'panic', 'e', 'er', 'err', 'eror', 'erro', 'error'] ci", + "SignalExpression": null, + "GroupBy": [], + "DisplayStyle": { + "Type": "Bar", + "LineFillToZeroY": false, + "LineShowMarkers": true, + "BarOverlaySum": true, + "UseLogarithmicScale": false, + "SuppressLegend": false, + "Palette": "Reds" + }, + "Having": null, + "OrderBy": [], + "Limit": null + } + ], + "DisplayStyle": { + "WidthColumns": 8, + "HeightRows": 1 + }, + "Description": "Created by `seqcli sample setup`" + }, + { + "Title": "Distinct Event Types", + "SignalExpression": null, + "Queries": [ + { + "DataSource": "Stream", + "Joins": [], + "Measurements": [ + { + "Value": "count(distinct(@EventType))", + "Label": "count" + } + ], + "Where": null, + "SignalExpression": null, + "GroupBy": [], + "DisplayStyle": { + "Type": "Value", + "LineFillToZeroY": false, + "LineShowMarkers": true, + "BarOverlaySum": false, + "UseLogarithmicScale": false, + "SuppressLegend": false, + "Palette": "Default" + }, + "Having": null, + "OrderBy": [], + "Limit": null + } + ], + "DisplayStyle": { + "WidthColumns": 4, + "HeightRows": 1 + }, + "Description": "Created by `seqcli sample setup`" + } + ] +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/dashboard-Production.template b/src/SeqCli/Sample/Templates/dashboard-Production.template new file mode 100644 index 00000000..e98d9731 --- /dev/null +++ b/src/SeqCli/Sample/Templates/dashboard-Production.template @@ -0,0 +1,85 @@ +{ + "$entity": "dashboard", + "OwnerId": null, + "Title": "Production", + "IsProtected": false, + "SignalExpression": null, + "Charts": [ + { + "Title": "Stock Level (kgs)", + "SignalExpression": null, + "Queries": [ + { + "DataSource": "Series", + "Joins": [], + "Measurements": [ + { + "Value": "max(StockLevel)", + "Label": "max" + } + ], + "Where": "Has(Blend) and Has(StockLevel)", + "SignalExpression": null, + "GroupBy": [ + "Blend" + ], + "DisplayStyle": { + "Type": "Line", + "LineFillToZeroY": false, + "LineShowMarkers": true, + "BarOverlaySum": false, + "UseLogarithmicScale": false, + "SuppressLegend": false, + "Palette": "Default" + }, + "Having": null, + "OrderBy": [], + "Limit": null + } + ], + "DisplayStyle": { + "WidthColumns": 12, + "HeightRows": 2 + }, + "Description": "Created by `seqcli sample setup`" + }, + { + "Title": "Completed Batches", + "SignalExpression": null, + "Queries": [ + { + "DataSource": "Stream", + "Joins": [], + "Measurements": [ + { + "Value": "count(*)", + "Label": "count" + } + ], + "Where": "@EventType = 0xB0AF9CDC", + "SignalExpression": null, + "GroupBy": [ + "RoastProfile" + ], + "DisplayStyle": { + "Type": "Line", + "LineFillToZeroY": false, + "LineShowMarkers": true, + "BarOverlaySum": false, + "UseLogarithmicScale": false, + "SuppressLegend": false, + "Palette": "Default" + }, + "Having": null, + "OrderBy": [], + "Limit": null + } + ], + "DisplayStyle": { + "WidthColumns": 12, + "HeightRows": 1 + }, + "Description": "Created by `seqcli sample setup`" + } + ] +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/expressionindex-16.template b/src/SeqCli/Sample/Templates/expressionindex-16.template new file mode 100644 index 00000000..eb034dad --- /dev/null +++ b/src/SeqCli/Sample/Templates/expressionindex-16.template @@ -0,0 +1,5 @@ +{ + "$entity": "expressionindex", + "Expression": "@TraceId", + "Description": "Automatically created" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/expressionindex-17.template b/src/SeqCli/Sample/Templates/expressionindex-17.template new file mode 100644 index 00000000..6b9d3bd0 --- /dev/null +++ b/src/SeqCli/Sample/Templates/expressionindex-17.template @@ -0,0 +1,5 @@ +{ + "$entity": "expressionindex", + "Expression": "@EventType", + "Description": "Automatically created" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/retentionpolicy-82.template b/src/SeqCli/Sample/Templates/retentionpolicy-82.template new file mode 100644 index 00000000..04b184a4 --- /dev/null +++ b/src/SeqCli/Sample/Templates/retentionpolicy-82.template @@ -0,0 +1,9 @@ +{ + "$entity": "retentionpolicy", + "RetentionTime": "30.00:00:00", + "RemovedSignalExpression": { + "Kind": "Signal", + "SignalId": ref("signal-Sample Data.template") + }, + "DataSource": "Stream" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/retentionpolicy-84.template b/src/SeqCli/Sample/Templates/retentionpolicy-84.template new file mode 100644 index 00000000..c7fdd7c2 --- /dev/null +++ b/src/SeqCli/Sample/Templates/retentionpolicy-84.template @@ -0,0 +1,6 @@ +{ + "$entity": "retentionpolicy", + "RetentionTime": "30.00:00:00", + "RemovedSignalExpression": null, + "DataSource": "Series" +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/retentionpolicy-Sample Data.template b/src/SeqCli/Sample/Templates/retentionpolicy-Sample Data.template deleted file mode 100644 index cb851f8c..00000000 --- a/src/SeqCli/Sample/Templates/retentionpolicy-Sample Data.template +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$entity": "retentionpolicy", - "RetentionTime": "30.00:00:00", - "RemovedSignalExpression": { - "SignalId": ref("signal-Sample Data.template"), - "Kind": "Signal" - } -} diff --git a/src/SeqCli/Sample/Templates/signal-Bad Request.template b/src/SeqCli/Sample/Templates/signal-Bad Request.template index 6e1c7e83..ae18264b 100644 --- a/src/SeqCli/Sample/Templates/signal-Bad Request.template +++ b/src/SeqCli/Sample/Templates/signal-Bad Request.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Bad Request", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "StatusCode = 400", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Inferred", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Batch Processing.template b/src/SeqCli/Sample/Templates/signal-Batch Processing.template index 23121501..436f1fc1 100644 --- a/src/SeqCli/Sample/Templates/signal-Batch Processing.template +++ b/src/SeqCli/Sample/Templates/signal-Batch Processing.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Batch Processing", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "Application = 'Roastery Batch Processing'", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Inferred", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Columns.template b/src/SeqCli/Sample/Templates/signal-Columns.template new file mode 100644 index 00000000..98fef896 --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Columns.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Columns", + "Description": "Created by `seqcli sample setup`", + "Filters": [], + "Columns": [ + { + "Expression": "SourceContext" + }, + { + "Expression": "StatusCode" + } + ], + "IsProtected": false, + "IsIndexSuppressed": true, + "Grouping": "None", + "ExplicitGroupName": null, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Database.template b/src/SeqCli/Sample/Templates/signal-Database.template index d8618dcc..7a6a863c 100644 --- a/src/SeqCli/Sample/Templates/signal-Database.template +++ b/src/SeqCli/Sample/Templates/signal-Database.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Database", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType = 0x94002ebf", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "None", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Errors.template b/src/SeqCli/Sample/Templates/signal-Errors.template new file mode 100644 index 00000000..748adb1e --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Errors.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Errors", + "Description": "Automatically created", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "@Level in ['f', 'fa', 'fat', 'ftl', 'fata', 'fatl', 'fatal', 'c', 'cr', 'cri', 'crt', 'crit', 'critical', 'alert', 'emerg', 'panic', 'e', 'er', 'err', 'eror', 'erro', 'error'] ci", + "FilterNonStrict": null + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Explicit", + "ExplicitGroupName": "@Level", + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Exceptions.template b/src/SeqCli/Sample/Templates/signal-Exceptions.template new file mode 100644 index 00000000..36131dea --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Exceptions.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Exceptions", + "Description": "Automatically created", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "@Exception is not null", + "FilterNonStrict": null + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Inferred", + "ExplicitGroupName": null, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-HTTP Requests.template b/src/SeqCli/Sample/Templates/signal-HTTP Requests.template index 5846570a..7b9e4f1d 100644 --- a/src/SeqCli/Sample/Templates/signal-HTTP Requests.template +++ b/src/SeqCli/Sample/Templates/signal-HTTP Requests.template @@ -1,18 +1,19 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "HTTP Requests", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, - "Filter": "@EventType = 0x672bd10e", - "FilterNonStrict": null + "Filter": "@EventType = 0xe80d5c02", + "FilterNonStrict": "@EventType = 0xE80D5C02" } ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "None", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Information.template b/src/SeqCli/Sample/Templates/signal-Information.template index 5ca80582..42de2669 100644 --- a/src/SeqCli/Sample/Templates/signal-Information.template +++ b/src/SeqCli/Sample/Templates/signal-Information.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Information", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@Level in ['inf', 'info', 'information'] ci", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "@Level" + "ExplicitGroupName": "@Level", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Internal Server Error.template b/src/SeqCli/Sample/Templates/signal-Internal Server Error.template index 0b590496..0873d61e 100644 --- a/src/SeqCli/Sample/Templates/signal-Internal Server Error.template +++ b/src/SeqCli/Sample/Templates/signal-Internal Server Error.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Internal Server Error", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "StatusCode = 500", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "StatusCode" + "ExplicitGroupName": "StatusCode", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Logs.template b/src/SeqCli/Sample/Templates/signal-Logs.template new file mode 100644 index 00000000..7cefe827 --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Logs.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Logs", + "Description": "Automatically created", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "not(has(@Start))", + "FilterNonStrict": null + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Explicit", + "ExplicitGroupName": "Kind", + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Not Found.template b/src/SeqCli/Sample/Templates/signal-Not Found.template index 09615b7f..2a1fb414 100644 --- a/src/SeqCli/Sample/Templates/signal-Not Found.template +++ b/src/SeqCli/Sample/Templates/signal-Not Found.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Not Found", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "StatusCode = 404", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Inferred", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Order Abandoned.template b/src/SeqCli/Sample/Templates/signal-Order Abandoned.template index a57a1880..fea92b2e 100644 --- a/src/SeqCli/Sample/Templates/signal-Order Abandoned.template +++ b/src/SeqCli/Sample/Templates/signal-Order Abandoned.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Order Abandoned", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType = 0x0c664bf4", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "Ordering" + "ExplicitGroupName": "Ordering", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Order Archived.template b/src/SeqCli/Sample/Templates/signal-Order Archived.template index 63a7e83e..6cfce579 100644 --- a/src/SeqCli/Sample/Templates/signal-Order Archived.template +++ b/src/SeqCli/Sample/Templates/signal-Order Archived.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Order Archived", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType = 0xbf71696d", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "Ordering" + "ExplicitGroupName": "Ordering", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Order Created.template b/src/SeqCli/Sample/Templates/signal-Order Created.template index c829fe8e..5cdb4519 100644 --- a/src/SeqCli/Sample/Templates/signal-Order Created.template +++ b/src/SeqCli/Sample/Templates/signal-Order Created.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Order Created", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType = 0x25c09546", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "Ordering" + "ExplicitGroupName": "Ordering", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Order Placed.template b/src/SeqCli/Sample/Templates/signal-Order Placed.template index a55f4792..8a0ca782 100644 --- a/src/SeqCli/Sample/Templates/signal-Order Placed.template +++ b/src/SeqCli/Sample/Templates/signal-Order Placed.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Order Placed", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType = 0xece21a0a", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "Ordering" + "ExplicitGroupName": "Ordering", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Order Shipped.template b/src/SeqCli/Sample/Templates/signal-Order Shipped.template index 7d4f95d4..75db1c57 100644 --- a/src/SeqCli/Sample/Templates/signal-Order Shipped.template +++ b/src/SeqCli/Sample/Templates/signal-Order Shipped.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Order Shipped", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType = 0x8cc54029", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "Ordering" + "ExplicitGroupName": "Ordering", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Roastery Production.template b/src/SeqCli/Sample/Templates/signal-Roastery Production.template new file mode 100644 index 00000000..5ee278d8 --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Roastery Production.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Roastery Production", + "Description": "Created by `seqcli sample setup`", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "Application = 'Roastery Production'", + "FilterNonStrict": "Application = 'Roastery Production'" + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Inferred", + "ExplicitGroupName": null, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Sample Data.template b/src/SeqCli/Sample/Templates/signal-Sample Data.template index 9ed2bd8f..f23535ef 100644 --- a/src/SeqCli/Sample/Templates/signal-Sample Data.template +++ b/src/SeqCli/Sample/Templates/signal-Sample Data.template @@ -1,18 +1,19 @@ -{ - "$entity": "signal", - "Title": "Sample Data", - "Description": "Created by `seqcli sample setup`", - "Filters": [ - { - "Description": null, - "DescriptionIsExcluded": false, - "Filter": "Origin = 'seqcli sample ingest'", - "FilterNonStrict": "Origin = 'seqcli sample ingest'" - } - ], - "Columns": [], - "IsProtected": false, - "Grouping": "Inferred", - "ExplicitGroupName": null, - "OwnerId": null -} +{ + "$entity": "signal", + "Title": "Sample Data", + "Description": "Created by `seqcli sample setup`", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "Origin = 'seqcli sample ingest'", + "FilterNonStrict": "Origin = 'seqcli sample ingest'" + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Inferred", + "ExplicitGroupName": null, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Spans.template b/src/SeqCli/Sample/Templates/signal-Spans.template new file mode 100644 index 00000000..361e7511 --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Spans.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Spans", + "Description": "Automatically created", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "has(@Start) and has(@SpanId)", + "FilterNonStrict": null + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Explicit", + "ExplicitGroupName": "Kind", + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Stock Level Warnings.template b/src/SeqCli/Sample/Templates/signal-Stock Level Warnings.template index cbb70729..e10240f6 100644 --- a/src/SeqCli/Sample/Templates/signal-Stock Level Warnings.template +++ b/src/SeqCli/Sample/Templates/signal-Stock Level Warnings.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Stock Level Warnings", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "@EventType in [4029526114, 1918830704]", "FilterNonStrict": "@EventType in [0xF02DB062, 0x725F0870]" @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Inferred", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Success.template b/src/SeqCli/Sample/Templates/signal-Success.template index 17fa6928..a47fb8fc 100644 --- a/src/SeqCli/Sample/Templates/signal-Success.template +++ b/src/SeqCli/Sample/Templates/signal-Success.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Success", "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "StatusCode >= 200 and StatusCode < 300", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Explicit", - "ExplicitGroupName": "StatusCode" + "ExplicitGroupName": "StatusCode", + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Warnings.template b/src/SeqCli/Sample/Templates/signal-Warnings.template new file mode 100644 index 00000000..82b6d2af --- /dev/null +++ b/src/SeqCli/Sample/Templates/signal-Warnings.template @@ -0,0 +1,19 @@ +{ + "$entity": "signal", + "Title": "Warnings", + "Description": "Automatically created", + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "@Level in ['w', 'wa', 'war', 'wrn', 'warn', 'warning'] ci", + "FilterNonStrict": null + } + ], + "Columns": [], + "IsProtected": false, + "IsIndexSuppressed": false, + "Grouping": "Explicit", + "ExplicitGroupName": "@Level", + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/signal-Web Frontend.template b/src/SeqCli/Sample/Templates/signal-Web Frontend.template index bccb19f6..f825a95c 100644 --- a/src/SeqCli/Sample/Templates/signal-Web Frontend.template +++ b/src/SeqCli/Sample/Templates/signal-Web Frontend.template @@ -1,11 +1,10 @@ { "$entity": "signal", - "OwnerId": arg("ownerId"), "Title": "Web Frontend", - "Description": "Created by `seqcli signal setup`", + "Description": "Created by `seqcli sample setup`", "Filters": [ { - "Description": null, + "Description": "Created by `seqcli sample setup`", "DescriptionIsExcluded": false, "Filter": "Application = 'Roastery Web Frontend'", "FilterNonStrict": null @@ -13,6 +12,8 @@ ], "Columns": [], "IsProtected": false, + "IsIndexSuppressed": false, "Grouping": "Inferred", - "ExplicitGroupName": null + "ExplicitGroupName": null, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/sqlquery-Available Properties.template b/src/SeqCli/Sample/Templates/sqlquery-Available Properties.template new file mode 100644 index 00000000..0636b078 --- /dev/null +++ b/src/SeqCli/Sample/Templates/sqlquery-Available Properties.template @@ -0,0 +1,8 @@ +{ + "$entity": "sqlquery", + "Title": "Available Properties", + "Description": "Automatically created", + "Sql": "select distinct(Keys(@Properties)) from stream", + "IsProtected": false, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/sqlquery-Count by Hour.template b/src/SeqCli/Sample/Templates/sqlquery-Count by Hour.template new file mode 100644 index 00000000..d39047b8 --- /dev/null +++ b/src/SeqCli/Sample/Templates/sqlquery-Count by Hour.template @@ -0,0 +1,8 @@ +{ + "$entity": "sqlquery", + "Title": "Count by Hour", + "Description": "Automatically created", + "Sql": "select count(*) from stream group by time(1h)", + "IsProtected": false, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/sqlquery-Latest as Table.template b/src/SeqCli/Sample/Templates/sqlquery-Latest as Table.template new file mode 100644 index 00000000..c02e062a --- /dev/null +++ b/src/SeqCli/Sample/Templates/sqlquery-Latest as Table.template @@ -0,0 +1,8 @@ +{ + "$entity": "sqlquery", + "Title": "Latest as Table", + "Description": "Automatically created", + "Sql": "select ToIsoString(@Timestamp) as Timestamp, @Level,\n ToHexString(@EventType) as EventType, @Message, @Exception, @Properties\nfrom stream\norder by Timestamp desc\nlimit 10", + "IsProtected": false, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/sqlquery-Order Items by Product Size.template b/src/SeqCli/Sample/Templates/sqlquery-Order Items by Product Size.template index d41e87c4..28feb4e6 100644 --- a/src/SeqCli/Sample/Templates/sqlquery-Order Items by Product Size.template +++ b/src/SeqCli/Sample/Templates/sqlquery-Order Items by Product Size.template @@ -1,8 +1,8 @@ { "$entity": "sqlquery", - "OwnerId": arg("ownerId"), "Title": "Order Items by Product Size", "Description": "Created by `seqcli sample setup`", "Sql": "select count(*) from stream group by Product.SizeInGrams", - "IsProtected": false + "IsProtected": false, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/sqlquery-Route Bindings.template b/src/SeqCli/Sample/Templates/sqlquery-Route Bindings.template index d2e11639..855604ba 100644 --- a/src/SeqCli/Sample/Templates/sqlquery-Route Bindings.template +++ b/src/SeqCli/Sample/Templates/sqlquery-Route Bindings.template @@ -1,8 +1,8 @@ { "$entity": "sqlquery", - "OwnerId": arg("ownerId"), "Title": "Route Bindings", "Description": "Created by `seqcli sample setup`", "Sql": "select first(Controller) as controller, first(Action) as action\nfrom stream\nwhere @EventType = 0x8E9D69C7 -- route bindings\ngroup by HttpMethod, RouteTemplate", - "IsProtected": false + "IsProtected": false, + "OwnerId": null } \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/sqlquery-Space by Event Type.template b/src/SeqCli/Sample/Templates/sqlquery-Space by Event Type.template new file mode 100644 index 00000000..87b7857e --- /dev/null +++ b/src/SeqCli/Sample/Templates/sqlquery-Space by Event Type.template @@ -0,0 +1,8 @@ +{ + "$entity": "sqlquery", + "Title": "Space by Event Type", + "Description": "Automatically created", + "Sql": "select count(*) as Occurrences,\n sum(Length(ToJson(@Data))) as TotalChars,\n ToHexString(first(@EventType)) as EventType,\n first(@MessageTemplate) as MessageTemplate\nfrom stream\ngroup by @EventType\norder by TotalChars desc\nlimit 10", + "IsProtected": false, + "OwnerId": null +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/view-Overview.template b/src/SeqCli/Sample/Templates/view-Overview.template new file mode 100644 index 00000000..8585721f --- /dev/null +++ b/src/SeqCli/Sample/Templates/view-Overview.template @@ -0,0 +1,35 @@ +{ + "$entity": "view", + "Title": "Overview", + "Description": "Created by `seqcli sample setup`", + "OwnerId": null, + "IsProtected": false, + "GroupKeyExpressions": [ + "Application" + ], + "Filters": [], + "DimensionFilters": [], + "PinnedMetrics": [ + { + "Metric": { + "Accessor": "HttpRequestDuration", + "GroupKey": [ + "Roastery Web Frontend" + ], + "Kind": "Exponential", + "Description": "The time taken to fully process a request.", + "Unit": "ms", + "Condition": "@Definitions.HttpRequestDuration.kind = 'Exponential' and @Definitions.HttpRequestDuration.unit = 'ms' and Application = 'Roastery Web Frontend'" + }, + "GroupKeyExpressions": [ + "Application" + ], + "NonGroupingCondition": null, + "UseLogarithmicScale": true, + "ShowZero": false, + "DisplayExpressionFilters": [], + "DisplayGroupKeyExpressions": [], + "DisplayDimensionFilters": [] + } + ] +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/view-Production.template b/src/SeqCli/Sample/Templates/view-Production.template new file mode 100644 index 00000000..2fcc8f5a --- /dev/null +++ b/src/SeqCli/Sample/Templates/view-Production.template @@ -0,0 +1,21 @@ +{ + "$entity": "view", + "Title": "Production", + "Description": "Created by `seqcli sample setup`", + "OwnerId": null, + "IsProtected": false, + "GroupKeyExpressions": [ + "MachineId", + "RoastProfile" + ], + "Filters": [ + { + "Description": "Created by `seqcli sample setup`", + "DescriptionIsExcluded": false, + "Filter": "Has(MachineId)", + "FilterNonStrict": "Has(MachineId)" + } + ], + "DimensionFilters": [], + "PinnedMetrics": [] +} \ No newline at end of file diff --git a/src/SeqCli/Sample/Templates/workspace-Sample.template "b/src/SeqCli/Sample/Templates/workspace-Seq Caf\303\251 \342\230\225.template" similarity index 71% rename from src/SeqCli/Sample/Templates/workspace-Sample.template rename to "src/SeqCli/Sample/Templates/workspace-Seq Caf\303\251 \342\230\225.template" index 5a3d1320..7b8ffb9c 100644 --- a/src/SeqCli/Sample/Templates/workspace-Sample.template +++ "b/src/SeqCli/Sample/Templates/workspace-Seq Caf\303\251 \342\230\225.template" @@ -1,27 +1,18 @@ { "$entity": "workspace", - "OwnerId": arg("ownerId"), - "Title": "Sample", + "Title": "Seq Café ☕", "Description": "Created by `seqcli sample setup`", + "OwnerId": null, "IsProtected": false, "DefaultSignalExpression": { - "SignalId": ref("signal-Sample Data.template"), - "Kind": "Signal" + "Kind": "Signal", + "SignalId": ref("signal-Columns.template") }, "Content": { - "DashboardIds": [ - ref("dashboard-Database.template"), - ref("dashboard-HTTP Requests.template"), - ref("dashboard-Orders.template") - ], - "QueryIds": [ - ref("sqlquery-Order Items by Product Size.template"), - ref("sqlquery-Route Bindings.template") - ], "SignalIds": [ - "signal-m33301", - "signal-m33302", - "signal-m33303", + ref("signal-Errors.template"), + ref("signal-Warnings.template"), + ref("signal-Exceptions.template"), ref("signal-Bad Request.template"), ref("signal-Batch Processing.template"), ref("signal-Database.template"), @@ -34,10 +25,22 @@ ref("signal-Order Created.template"), ref("signal-Order Placed.template"), ref("signal-Order Shipped.template"), - "signal-29", ref("signal-Stock Level Warnings.template"), ref("signal-Success.template"), - ref("signal-Web Frontend.template") - ] + ref("signal-Web Frontend.template"), + ref("signal-Sample Data.template"), + ref("signal-Roastery Production.template"), + ref("signal-Columns.template") + ], + "QueryIds": [ + ref("sqlquery-Order Items by Product Size.template"), + ref("sqlquery-Route Bindings.template") + ], + "DashboardIds": [ + ref("dashboard-Database.template"), + ref("dashboard-HTTP Requests.template"), + ref("dashboard-Orders.template") + ], + "ViewIds": [] } } \ No newline at end of file diff --git a/src/SeqCli/Syntax/AliasedExpression.cs b/src/SeqCli/Syntax/AliasedExpression.cs new file mode 100644 index 00000000..ed1fbd53 --- /dev/null +++ b/src/SeqCli/Syntax/AliasedExpression.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. + +namespace SeqCli.Syntax; + +/// +/// A loose and abstract `{expression} ci as {alias}`. +/// +readonly struct AliasedExpression(string expression, string? alias, bool isCaseInsensitive) +{ + public string Expression { get; } = expression; + + public string? Alias { get; } = alias; + + public bool IsCaseInsensitive { get; } = isCaseInsensitive; +} diff --git a/src/SeqCli/Syntax/AliasedExpressionParser.cs b/src/SeqCli/Syntax/AliasedExpressionParser.cs new file mode 100644 index 00000000..d3084d84 --- /dev/null +++ b/src/SeqCli/Syntax/AliasedExpressionParser.cs @@ -0,0 +1,57 @@ +// 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.Text.RegularExpressions; + +namespace SeqCli.Syntax; + +static partial class AliasedExpressionParser +{ + // " as " + [GeneratedRegex(@"\s+as\s+(?[\p{L}_][\p{L}\p{N}_]*)$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex AsAlias(); + + // " ci" + [GeneratedRegex(@"\s+ci$", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex Ci(); + + public static AliasedExpression ParseExpression(string fragment, bool allowCaseInsensitive = false) + { + if (fragment == null) throw new ArgumentNullException(nameof(fragment)); + + var expression = fragment.Trim(); + + string? alias = null; + var aliasMatch = AsAlias().Match(expression); + if (aliasMatch.Success) + { + alias = aliasMatch.Groups["alias"].Value; + expression = expression[..aliasMatch.Index].TrimEnd(); + } + + var isCaseInsensitive = false; + if (allowCaseInsensitive) + { + var ciMatch = Ci().Match(expression); + if (ciMatch.Success) + { + isCaseInsensitive = true; + expression = expression[..ciMatch.Index].TrimEnd(); + } + } + + return expression.Length == 0 ? throw new FormatException($"The fragment `{fragment}` has no expression.") : new AliasedExpression(expression, alias, isCaseInsensitive); + } +} diff --git a/test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs b/test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs new file mode 100644 index 00000000..7653e273 --- /dev/null +++ b/test/SeqCli.EndToEnd/Alert/AlertBasicsTestCase.cs @@ -0,0 +1,49 @@ +using System.Threading.Tasks; +using Seq.Api; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Alert; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class AlertBasicsTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var alert = await connection.Alerts.TemplateAsync(); + alert.Title = "TestBasicsAlert"; + alert.OwnerId = null; + alert = await connection.Alerts.AddAsync(alert); + + var exit = runner.Exec("alert list", "-i alert-none"); + Assert.Equal(1, exit); + + exit = runner.Exec("alert list", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + var output = runner.LastRunProcess?.Output; + Assert.Equal($"{alert.Id} TestBasicsAlert", output?.Trim()); + + exit = runner.Exec("alert disable", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + alert = await connection.Alerts.FindAsync(alert.Id); + Assert.True(alert.IsDisabled); + + exit = runner.Exec("alert enable", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + alert = await connection.Alerts.FindAsync(alert.Id); + Assert.False(alert.IsDisabled); + + exit = runner.Exec("alert remove", "-t TestBasicsAlert"); + Assert.Equal(0, exit); + + exit = runner.Exec("alert list", $"-i {alert.Id}"); + Assert.Equal(1, exit); + } +} diff --git a/test/SeqCli.EndToEnd/Alert/AlertCreateAllArgsTestCase.cs b/test/SeqCli.EndToEnd/Alert/AlertCreateAllArgsTestCase.cs new file mode 100644 index 00000000..ddae472f --- /dev/null +++ b/test/SeqCli.EndToEnd/Alert/AlertCreateAllArgsTestCase.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using Seq.Api.Model.LogEvents; +using Seq.Api.Model.Shared; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Alert; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class AlertCreateAllArgsTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + // An app instance is required to exercise `--notification-app`. + var exit = runner.Exec("app install", "--package-id Seq.App.EmailPlus"); + Assert.Equal(0, exit); + + var app = (await connection.Apps.ListAsync()).Single(); + + exit = runner.Exec("appinstance create", + $"-t AlertNotify --app {app.Id} -p To=example@example.com -p From=example@example.com -p Host=localhost"); + Assert.Equal(0, exit); + + var appInstance = (await connection.AppInstances.ListAsync()).Single(); + + // `stream` alert + exit = runner.Exec("alert create", + "-t AllArgsAlert --description \"Covers every alert create option\" --from stream " + + "--signal signal-m33301 --where \"@Level = 'Error'\" " + + "--select \"count(*) as errors\" --select \"max(Elapsed) as slowest\" " + + "--group-by \"ServiceName ci as service\" --group-by \"Environment\" " + + "--window 5m --having \"errors > 10\" --notification-level Error --suppression-time 10m " + + $"--notification-app {appInstance.Id} --protected --disabled"); + Assert.Equal(0, exit); + + var testAlert = (await connection.Alerts.ListAsync(shared: true)).First(x => x.Title == "AllArgsAlert"); + + Assert.Equal("Covers every alert create option", testAlert.Description); + Assert.Equal(DataSource.Stream, testAlert.DataSource); + Assert.NotNull(testAlert.SignalExpression); + Assert.False(string.IsNullOrWhiteSpace(testAlert.Where)); + Assert.Equal(2, testAlert.Select.Count); + Assert.Equal("errors", testAlert.Select[0].Label); + Assert.Equal("slowest", testAlert.Select[1].Label); + Assert.Equal(2, testAlert.GroupBy.Count); + Assert.Equal("ServiceName", testAlert.GroupBy[0].Value); + Assert.Equal("service", testAlert.GroupBy[0].Label); + Assert.True(testAlert.GroupBy[0].IsCaseInsensitive); + Assert.Equal("Environment", testAlert.GroupBy[1].Value); + Assert.False(testAlert.GroupBy[1].IsCaseInsensitive); + Assert.Equal(TimeSpan.FromMinutes(5), testAlert.TimeGrouping); + Assert.False(string.IsNullOrWhiteSpace(testAlert.Having)); + Assert.Equal(LogEventLevel.Error, testAlert.NotificationLevel); + Assert.Equal(TimeSpan.FromMinutes(10), testAlert.SuppressionTime); + Assert.Contains(testAlert.NotificationChannels, c => c.NotificationAppInstanceId == appInstance.Id); + Assert.True(testAlert.IsProtected); + Assert.True(testAlert.IsDisabled); + + // `series` alert + exit = runner.Exec("alert create", + "-t SeriesAlert --from series --lateral \"unnest(commit_duration.buckets) as bucket\" " + + "--where \"Has(commit_duration.buckets)\" " + + "--select \"phist(bucket.midpoint, bucket.count, 95) as p95\" " + + "--window 5m --having \"p95 > 10\" --notification-level Error --suppression-time 10m"); + Assert.Equal(0, exit); + + var seriesAlert = (await connection.Alerts.ListAsync(shared: true)).First(x => x.Title == "SeriesAlert"); + Assert.Equal(DataSource.Series, seriesAlert.DataSource); + var lateralJoin = Assert.Single(seriesAlert.Joins); + Assert.Equal(JoinKind.Lateral, lateralJoin.Kind); + Assert.Equal("unnest(commit_duration.buckets)", lateralJoin.SetFunctionCall); + Assert.Equal("bucket", lateralJoin.Alias); + } +} diff --git a/test/SeqCli.EndToEnd/Alert/AlertCreateMinimalTestCase.cs b/test/SeqCli.EndToEnd/Alert/AlertCreateMinimalTestCase.cs new file mode 100644 index 00000000..cfdf1694 --- /dev/null +++ b/test/SeqCli.EndToEnd/Alert/AlertCreateMinimalTestCase.cs @@ -0,0 +1,30 @@ +using System.Linq; +using System.Threading.Tasks; +using Seq.Api; +using Seq.Api.Model.Shared; +using SeqCli.EndToEnd.Support; +using Serilog; +using Xunit; + +namespace SeqCli.EndToEnd.Alert; + +[CliTestCase(MinimumApiVersion = "2026.1.0")] +public class AlertCreateMinimalTestCase : ICliTestCase +{ + public async Task ExecuteAsync( + SeqConnection connection, + ILogger logger, + CliCommandRunner runner) + { + var exit = runner.Exec("alert create", "-t MinimalAlert"); + Assert.Equal(0, exit); + + var alert = (await connection.Alerts.ListAsync(shared: true)).First(x => x.Title == "MinimalAlert"); + + Assert.Equal("MinimalAlert", alert.Title); + Assert.Equal(DataSource.Stream, alert.DataSource); + + exit = runner.Exec("alert list", "-t MinimalAlert"); + Assert.Equal(0, exit); + } +} diff --git a/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs b/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs index 4bffb7a3..d3d16c31 100644 --- a/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs +++ b/test/SeqCli.EndToEnd/ApiKey/ApiKeyCreateTestCase.cs @@ -13,11 +13,11 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun var exit = runner.Exec("apikey create", "-t Test"); Assert.Equal(0, exit); - exit = runner.Exec("apikey list", "-t Test --json --no-color"); + exit = runner.Exec("apikey list", "-t Test --json"); Assert.Equal(0, exit); var output = runner.LastRunProcess!.Output; - Assert.Contains("\"AssignedPermissions\": [\"Ingest\"]", output); + Assert.Contains("\"AssignedPermissions\":[\"Ingest\"]", output); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs b/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs index fe583ad6..dad598e3 100644 --- a/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs +++ b/test/SeqCli.EndToEnd/ApiKey/ApiKeyDelegatePermissionsTestCase.cs @@ -21,11 +21,11 @@ public Task ExecuteAsync(SeqConnection connection, ILogger logger, CliCommandRun "-t Setup --permissions=Setup,Write --connect-username=carol --connect-password=\"test@1234\""); Assert.Equal(0, exit); - exit = runner.Exec("apikey list", "-t Setup --json --no-color"); + exit = runner.Exec("apikey list", "-t Setup --json"); Assert.Equal(0, exit); var output = runner.LastRunProcess.Output; - Assert.Contains("\"AssignedPermissions\": [\"Setup\", \"Write\"]", output); + Assert.Contains("\"AssignedPermissions\":[\"Setup\",\"Write\"]", output); return Task.CompletedTask; } diff --git a/test/SeqCli.EndToEnd/Args.cs b/test/SeqCli.EndToEnd/Args.cs index 322c408a..95724243 100644 --- a/test/SeqCli.EndToEnd/Args.cs +++ b/test/SeqCli.EndToEnd/Args.cs @@ -17,12 +17,17 @@ public Regex[] TestCases() => args static Regex ToArgRegex(string arg) => new(arg.Replace(".", "\\.").Replace("*", ".*")); public bool Multiuser() => args.Any(a => a == "--license-certificate-stdin"); - + + const string DockerTagArg = "--docker-tag="; + public bool UseDockerSeq([NotNullWhen(true)] out string? imageTag, [NotNullWhen(true)] out string? containerRuntime) { if (args.Any(a => a == "--docker-server")) { - imageTag = args.Any(a => a == "--pre") ? "preview" : "latest"; + imageTag = args + .Where(a => a.StartsWith(DockerTagArg)) + .Select(a => a[DockerTagArg.Length..]) + .LastOrDefault() ?? "latest"; containerRuntime = args.Any(a => a == "--podman") ? "podman" : "docker"; return true; } diff --git a/test/SeqCli.EndToEnd/Properties/launchSettings.json b/test/SeqCli.EndToEnd/Properties/launchSettings.json index a535203b..1ffd4e2d 100644 --- a/test/SeqCli.EndToEnd/Properties/launchSettings.json +++ b/test/SeqCli.EndToEnd/Properties/launchSettings.json @@ -14,11 +14,11 @@ }, "SeqCli.EndToEnd (datalust/seq:preview)": { "commandName": "Project", - "commandLineArgs": "--docker-server --pre" + "commandLineArgs": "--docker-server --docker-tag=preview" }, "SeqCli.EndToEnd (McpMetrics*)": { "commandName": "Project", - "commandLineArgs": "--docker-server --pre McpMetrics" + "commandLineArgs": "--docker-server --docker-tag=preview McpMetrics" } } } diff --git a/test/SeqCli.EndToEnd/Sample/SampleIngestTestCase.cs b/test/SeqCli.EndToEnd/Sample/SampleIngestTestCase.cs index 06e5020e..903dbcdf 100644 --- a/test/SeqCli.EndToEnd/Sample/SampleIngestTestCase.cs +++ b/test/SeqCli.EndToEnd/Sample/SampleIngestTestCase.cs @@ -25,7 +25,7 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm Assert.NotEmpty(events); var sampleWorkspace = (await connection.Workspaces.ListAsync(shared: true)) - .SingleOrDefault(w => w.Title == "Sample"); + .SingleOrDefault(w => w.Title == "Seq Café ☕"); Assert.NotNull(sampleWorkspace); } diff --git a/test/SeqCli.EndToEnd/Sample/SampleSetupTestCase.cs b/test/SeqCli.EndToEnd/Sample/SampleSetupTestCase.cs index c2155d61..30dfc44e 100644 --- a/test/SeqCli.EndToEnd/Sample/SampleSetupTestCase.cs +++ b/test/SeqCli.EndToEnd/Sample/SampleSetupTestCase.cs @@ -15,7 +15,7 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm Assert.Equal(0, exit); var sampleWorkspace = (await connection.Workspaces.ListAsync(shared: true)) - .SingleOrDefault(w => w.Title == "Sample"); + .SingleOrDefault(w => w.Title == "Seq Café ☕"); Assert.NotNull(sampleWorkspace); } diff --git a/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs b/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs index 6f72d949..b6300ccd 100644 --- a/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs +++ b/test/SeqCli.EndToEnd/Shared/UpdateCommandTests.cs @@ -37,9 +37,16 @@ public async Task ExecuteAsync(SeqConnection connection, ILogger logger, CliComm var workspace = await connection.Workspaces.TemplateAsync(); workspace.OwnerId = null; await connection.Workspaces.AddAsync(workspace); - + + // One alert... + var alert = await connection.Alerts.TemplateAsync(); + alert.Title = "UpdateTestAlert"; + alert.OwnerId = null; + await connection.Alerts.AddAsync(alert); + foreach (var commandGroup in new[] { + "alert", "apikey", "appinstance", "feed", diff --git a/test/SeqCli.Tests/Csv/CsvWriterTests.cs b/test/SeqCli.Tests/Csv/CsvWriterTests.cs new file mode 100644 index 00000000..cf4dbeb9 --- /dev/null +++ b/test/SeqCli.Tests/Csv/CsvWriterTests.cs @@ -0,0 +1,60 @@ +#nullable enable +using System; +using System.IO; +using Seq.Api.Model.Data; +using SeqCli.Csv; +using Serilog.Templates.Themes; +using Xunit; + +namespace SeqCli.Tests.Csv; + +public class CsvWriterTests +{ + const char Escape = '\x1b'; + + // `CsvWriter` writes to the console without going through Serilog's console sink, so unlike the other + // output paths it has no opportunity to suppress the theme itself. + [Fact] + public void QueryResultsAreNotColorizedWhenOutputIsRedirected() + { + var rendered = Render(RedirectedTheme(forceColor: false)); + + Assert.DoesNotContain(Escape, rendered); + Assert.Equal($"\"Events\",\"Application\"{Environment.NewLine}\"852\",\"Roastery \"\"Web\"\" Frontend\"{Environment.NewLine}", rendered); + } + + [Fact] + public void QueryResultsAreColorizedWhenColorIsForcedForRedirectedOutput() + { + Assert.Contains(Escape, Render(RedirectedTheme(forceColor: true))); + } + + [Fact] + public void QueryErrorsAreNotColorizedWhenOutputIsRedirected() + { + var rendered = Render(RedirectedTheme(forceColor: false), new QueryResultPart + { + Error = "The query could not be executed.", + Reasons = [] + }); + + Assert.DoesNotContain(Escape, rendered); + Assert.Contains("The query could not be executed.", rendered); + } + + static TemplateTheme? RedirectedTheme(bool forceColor) => + forceColor ? TemplateTheme.Code : null; + + static string Render(TemplateTheme? theme, QueryResultPart? result = null) + { + result ??= new QueryResultPart + { + Columns = ["Events", "Application"], + Rows = [[852, "Roastery \"Web\" Frontend"]] + }; + + var output = new StringWriter(); + CsvWriter.WriteQueryResult(result, v => v?.ToString() ?? "null", theme, output); + return output.ToString(); + } +} diff --git a/test/SeqCli.Tests/Output/OutputFormatTests.cs b/test/SeqCli.Tests/Output/OutputFormatTests.cs new file mode 100644 index 00000000..af2e5059 --- /dev/null +++ b/test/SeqCli.Tests/Output/OutputFormatTests.cs @@ -0,0 +1,109 @@ +using System; +using SeqCli.Config; +using SeqCli.Output; +using Xunit; + +namespace SeqCli.Tests.Output; + +public class OutputFormatTests +{ + static OutputFormat Create( + bool? noColor = null, + bool? forceColor = null, + bool disableColor = false, + bool noColorSetInEnvironment = false, + bool outputIsRedirected = true, + bool supportsAnsiEscapes = true, + OutputSyntax syntax = OutputSyntax.Text) + => new( + syntax, + noColor, + forceColor, + new SeqCliOutputConfig { DisableColor = disableColor }, + outputTemplate: null, + noColorSetInEnvironment, + outputIsRedirected, + supportsAnsiEscapes); + + [Fact] + public void RedirectedOutputIsNotThemedByDefault() + { + var format = Create(outputIsRedirected: true); + Assert.Null(format.TemplateTheme); + } + + [Fact] + public void RedirectedOutputIsThemedWhenColorIsForced() + { + var format = Create(forceColor: true, outputIsRedirected: true); + Assert.NotNull(format.TemplateTheme); + } + + [Fact] + public void TerminalOutputIsThemed() + { + var format = Create(outputIsRedirected: false); + Assert.NotNull(format.TemplateTheme); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NoColorSuppressesTheThemeRegardlessOfRedirection(bool outputIsRedirected) + { + var format = Create(noColor: true, outputIsRedirected: outputIsRedirected); + Assert.Null(format.TemplateTheme); + } + + [Fact] + public void RedirectedJsonOutputIsNotThemedByDefault() + { + var format = Create(syntax: OutputSyntax.Json, outputIsRedirected: true); + Assert.Null(format.TemplateTheme); + } + + [Fact] + public void RedirectedJsonOutputIsThemedWhenColorIsForced() + { + var format = Create(syntax: OutputSyntax.Json, forceColor: true, outputIsRedirected: true); + Assert.NotNull(format.TemplateTheme); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NoColorSuppressesTheJsonThemeRegardlessOfRedirection(bool outputIsRedirected) + { + var format = Create(syntax: OutputSyntax.Json, noColor: true, outputIsRedirected: outputIsRedirected); + Assert.Null(format.TemplateTheme); + } + + [Theory] + // noColorFlag, forceColorFlag, disableColor, noColorSetInEnvironment, supportsAnsiEscapes, expected + [InlineData(null, null, false, false, true, false)] // Color is on by default. + [InlineData(null, null, false, true, true, true)] // `NO_COLOR` disables color. + [InlineData(null, true, false, true, true, false)] // `--force-color` is more specific than `NO_COLOR`. + [InlineData(true, null, false, false, true, true)] // `--no-color` disables color. + [InlineData(true, true, false, false, true, true)] // `--no-color` beats `--force-color`. + [InlineData(null, null, true, false, true, true)] // `output.disableColor` disables color. + [InlineData(null, true, true, false, true, true)] // `--force-color` doesn't override configuration. + [InlineData(null, null, false, false, false, true)] // No ANSI escape support disables color. + [InlineData(null, true, false, false, false, true)] // ...and `--force-color` can't override it. + public void NoColorIsResolvedFromFlagsConfigurationAndEnvironment( + bool? noColorFlag, + bool? forceColorFlag, + bool disableColor, + bool noColorSetInEnvironment, + bool supportsAnsiEscapes, + bool expected) + { + Assert.Equal(expected, OutputFormat.ResolveNoColor(noColorFlag, forceColorFlag, new SeqCliOutputConfig { DisableColor = disableColor }, noColorSetInEnvironment, supportsAnsiEscapes)); + } + + [Fact] + public void TerminalOutputIsNotThemedWithoutAnsiEscapeSupport() + { + var format = Create(outputIsRedirected: false, supportsAnsiEscapes: false); + Assert.Null(format.TemplateTheme); + } +} diff --git a/test/SeqCli.Tests/Output/TextFormattersTests.cs b/test/SeqCli.Tests/Output/TextFormattersTests.cs new file mode 100644 index 00000000..f3548e0d --- /dev/null +++ b/test/SeqCli.Tests/Output/TextFormattersTests.cs @@ -0,0 +1,124 @@ +#nullable enable +using System; +using System.IO; +using SeqCli.Output; +using SeqCli.Tests.Support; +using Serilog.Events; +using Serilog.Parsing; +using Serilog.Templates.Themes; +using Xunit; + +namespace SeqCli.Tests.Output; + +public class TextFormattersTests +{ + const char Escape = '\x1b'; + static readonly DateTimeOffset FixedTimestamp = new(2024, 1, 1, 10, 0, 1, 250, TimeSpan.Zero); + + [Fact] + public void ThemedJsonOutputIsColorizedRegardlessOfRedirection() + { + Assert.Contains(Escape, RenderJson(TemplateTheme.Code)); + } + + [Fact] + public void UnthemedJsonOutputIsNotColorized() + { + Assert.DoesNotContain(Escape, RenderJson(theme: null)); + } + + [Fact] + public void LogEventsAreFormattedWithTheDefaultTextTemplate() + { + var evt = SomeLogEvent( + level: LogEventLevel.Warning, + properties: new LogEventProperty("Name", new ScalarValue("world"))); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 WRN] Hello, world!{Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void ExceptionsAreIncludedInTextOutput() + { + var evt = SomeLogEvent( + FixedTimestamp, + LogEventLevel.Error, + new Exception("Boom!"), + new LogEventProperty("Name", new ScalarValue("world"))); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 ERR] Hello, world!{Environment.NewLine}System.Exception: Boom!{Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void SpanElapsedTimeIsComputedFromTheStartTimestampProperty() + { + // Events retrieved from the Seq API carry span start timestamps in ISO-8601 `@st` properties. + var evt = SomeLogEvent(FixedTimestamp, properties: + [ + new LogEventProperty("Name", new ScalarValue("world")), + new LogEventProperty("@st", new ScalarValue("2024-01-01T10:00:00.0000000Z")) + ]); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 INF] Hello, world! (1250 ms){Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void SpanElapsedTimeIsComputedFromTheSurrogateStartTimestampProperty() + { + // Ingested spans carry a surrogate `SpanStartTimestamp` property with a `DateTime` value. + var evt = SomeLogEvent(FixedTimestamp, properties: + [ + new LogEventProperty("Name", new ScalarValue("world")), + new LogEventProperty("SpanStartTimestamp", new ScalarValue( + FixedTimestamp.UtcDateTime.AddMilliseconds(-1.5))) + ]); + + Assert.Equal( + $"[2024-01-01T10:00:01.2500000+00:00 INF] Hello, world! (1.5 ms){Environment.NewLine}", + RenderText(evt)); + } + + [Fact] + public void ACustomOutputTemplateReplacesTheDefault() + { + var evt = SomeLogEvent(properties: new LogEventProperty("Name", new ScalarValue("world"))); + + Assert.Equal($"INF Hello, world!{Environment.NewLine}", RenderText(evt, $"{{@l:u3}} {{@m}}{Environment.NewLine}")); + } + + static LogEvent SomeLogEvent( + DateTimeOffset? timestamp = null, + LogEventLevel level = LogEventLevel.Information, + Exception? exception = null, + params LogEventProperty[] properties) + { + return new LogEvent( + timestamp ?? FixedTimestamp, + level, + exception, + new MessageTemplateParser().Parse("Hello, {Name}!"), + properties); + } + + static string RenderText(LogEvent evt, string? outputTemplate = null) + { + var output = new StringWriter(); + TextFormatters.Plain(theme: null, outputTemplate).Format(evt, output); + return output.ToString(); + } + + static string RenderJson(TemplateTheme? theme) + { + var evt = OutputFormat.ToSerilogEvent(Some.MakeEvent(e => e.Properties = [])); + + var output = new StringWriter(); + TextFormatters.Json(theme).Format(evt, output); + return output.ToString(); + } +} diff --git a/test/SeqCli.Tests/Syntax/AliasedExpressionParserTests.cs b/test/SeqCli.Tests/Syntax/AliasedExpressionParserTests.cs new file mode 100644 index 00000000..43cceeca --- /dev/null +++ b/test/SeqCli.Tests/Syntax/AliasedExpressionParserTests.cs @@ -0,0 +1,41 @@ +using System; +using SeqCli.Syntax; +using Xunit; + +#nullable enable + +namespace SeqCli.Tests.Syntax; + +public class AliasedExpressionParserTests +{ + [Theory] + [InlineData("count(*)", "count(*)", null)] + [InlineData("count(*) as errors", "count(*)", "errors")] + [InlineData("phist(bucket.midpoint, bucket.count, 95) as p95", "phist(bucket.midpoint, bucket.count, 95)", "p95")] + [InlineData("max(a as b)", "max(a as b)", null)] + [InlineData("ServiceName ci as service", "ServiceName ci", "service")] + [InlineData("ServiceName ci", "ServiceName ci", null)] + public void ParsesExpressionAndAlias(string fragment, string expression, string? alias) + { + var parsed = AliasedExpressionParser.ParseExpression(fragment); + + Assert.Equal(expression, parsed.Expression); + Assert.Equal(alias, parsed.Alias); + Assert.False(parsed.IsCaseInsensitive); + } + + [Theory] + [InlineData("ServiceName", "ServiceName", null, false)] + [InlineData("ServiceName ci", "ServiceName", null, true)] + [InlineData("ServiceName as service", "ServiceName", "service", false)] + [InlineData("ServiceName ci as service", "ServiceName", "service", true)] + [InlineData("StartsWith(@Message, 'a') ci as sw", "StartsWith(@Message, 'a')", "sw", true)] + public void ParsesCaseInsensitiveModifierWhenAllowed(string fragment, string expression, string? alias, bool isCaseInsensitive) + { + var parsed = AliasedExpressionParser.ParseExpression(fragment, allowCaseInsensitive: true); + + Assert.Equal(expression, parsed.Expression); + Assert.Equal(alias, parsed.Alias); + Assert.Equal(isCaseInsensitive, parsed.IsCaseInsensitive); + } +}