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/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/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/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); }