diff --git a/BinDays.Api/Controllers/CollectorsController.cs b/BinDays.Api/Controllers/CollectorsController.cs index 76531bc6..14e1f78e 100644 --- a/BinDays.Api/Controllers/CollectorsController.cs +++ b/BinDays.Api/Controllers/CollectorsController.cs @@ -1,10 +1,9 @@ -namespace BinDays.Api.Controllers +namespace BinDays.Api.Controllers { using BinDays.Api.Collectors.Exceptions; using BinDays.Api.Collectors.Models; using BinDays.Api.Collectors.Services; using BinDays.Api.Collectors.Utilities; - using BinDays.Api.Incidents; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; @@ -32,11 +31,6 @@ public class CollectorsController : ControllerBase /// private readonly ILogger _logger; - /// - /// Store for recording failed incidents. - /// - private readonly IIncidentStore _incidentStore; - /// /// Distributed cache for storing responses. /// @@ -53,13 +47,11 @@ public class CollectorsController : ControllerBase /// Service for retrieving collector information. /// Logger for the controller. /// Distributed cache for storing responses. - /// Store for recording failed incidents. - public CollectorsController(CollectorService collectorService, ILogger logger, IDistributedCache cache, IIncidentStore incidentStore) + public CollectorsController(CollectorService collectorService, ILogger logger, IDistributedCache cache) { _collectorService = collectorService; _logger = logger; _cache = cache; - _incidentStore = incidentStore; } /// @@ -73,7 +65,7 @@ private static string FormatPostcodeForCacheKey(string postcode) } /// - /// Attempts to retrieve and deserialize an object from the cache. + /// Attempts to retrieve and deserialize an object from the cache. /// Handles deserialization errors by evicting the bad cache entry. /// /// The type to deserialize into. @@ -176,7 +168,6 @@ public IActionResult GetCollector(string postcode, [FromBody] ClientSideResponse catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred while retrieving collector for postcode: {Postcode}.", postcode); - RecordIncident(null, IncidentOperation.GetCollector, ex); return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching the collector for the specified postcode. Please try again later."); } } @@ -231,7 +222,6 @@ public IActionResult GetAddresses(string govUkId, string postcode, [FromBody] Cl catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred while retrieving addresses for gov.uk ID: {GovUkId}, postcode: {Postcode}.", govUkId, postcode); - RecordIncident(govUkId, IncidentOperation.GetAddresses, ex); return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching addresses. Please try again later."); } } @@ -297,70 +287,8 @@ public IActionResult GetBinDays(string govUkId, string postcode, string uid, [Fr catch (Exception ex) { _logger.LogError(ex, "An unexpected error occurred while retrieving bin days for gov.uk ID: {GovUkId}, postcode: {Postcode}, UID: {Uid}.", govUkId, postcode, uid); - RecordIncident(govUkId, IncidentOperation.GetBinDays, ex); return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching bin days. Please try again later."); } } - - /// - /// Records an unexpected incident for diagnostics. - /// - /// The collector identifier, if known. - /// The operation that was being performed. - /// The triggering exception. - private void RecordIncident(string? govUkId, IncidentOperation operation, Exception exception) - { - var normalisedGovUkId = string.IsNullOrWhiteSpace(govUkId) ? string.Empty : govUkId.Trim().ToLowerInvariant(); - - var incident = new IncidentRecord - { - IncidentId = Guid.NewGuid(), - GovUkId = normalisedGovUkId, - OccurredUtc = DateTime.UtcNow, - Category = Classify(exception), - Operation = operation, - MessageHash = ComputeHash(exception), - ExceptionType = exception.GetType().FullName ?? exception.GetType().Name, - }; - - try - { - _incidentStore.RecordIncident(incident); - } - catch (Exception storeException) - { - _logger.LogError(storeException, "Failed to record incident for collector {GovUkId}.", normalisedGovUkId); - } - } - - /// - /// Classifies an exception into an incident category. - /// - /// The exception to classify. - /// The incident category. - private static IncidentCategory Classify(Exception exception) - { - return exception switch - { - HttpRequestException or TimeoutException or TaskCanceledException => IncidentCategory.CollectorFailure, - System.Text.Json.JsonException or FormatException or InvalidOperationException => IncidentCategory.IntegrationChanged, - _ => IncidentCategory.SystemFailure, - }; - } - - /// - /// Computes a deterministic hash for an exception. - /// - /// The exception to hash. - /// A hexadecimal hash string. - private static string ComputeHash(Exception exception) - { - var payload = Encoding.UTF8.GetBytes( - $"{exception.GetType().FullName}|{exception.TargetSite?.ToString() ?? "UnknownTarget"}|{exception.Message}" - ); - var hash = SHA256.HashData(payload); - - return Convert.ToHexString(hash); - } } } diff --git a/BinDays.Api/Controllers/HealthIncidentsController.cs b/BinDays.Api/Controllers/HealthIncidentsController.cs deleted file mode 100644 index 1cfe5c44..00000000 --- a/BinDays.Api/Controllers/HealthIncidentsController.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace BinDays.Api.Controllers -{ - using BinDays.Api.Incidents; - using Microsoft.AspNetCore.Mvc; - using Microsoft.Extensions.Logging; - using System; - using System.Collections.Generic; - - /// - /// Provides health-focused incident feeds. - /// - [ApiController] - [Route("health")] - public sealed class HealthIncidentsController : ControllerBase - { - private readonly IIncidentStore _incidentStore; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The incident store. - /// Logger instance. - public HealthIncidentsController(IIncidentStore incidentStore, ILogger logger) - { - _incidentStore = incidentStore; - _logger = logger; - } - - /// - /// Gets all recorded incidents ordered from newest to oldest. - /// - /// The incidents. - [HttpGet("incidents")] - public ActionResult> GetIncidents() - { - try - { - return Ok(_incidentStore.GetIncidents()); - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to retrieve incidents."); - return StatusCode(StatusCodes.Status500InternalServerError, "An unexpected error occurred while fetching incidents. Please try again later."); - } - } - } -} diff --git a/BinDays.Api/Incidents/IIncidentStore.cs b/BinDays.Api/Incidents/IIncidentStore.cs deleted file mode 100644 index 55355236..00000000 --- a/BinDays.Api/Incidents/IIncidentStore.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System.Collections.Generic; - - /// - /// Persists incident records for health monitoring. - /// - public interface IIncidentStore - { - /// - /// Records a new incident. - /// - /// The incident to store. - void RecordIncident(IncidentRecord incident); - - /// - /// Retrieves all recorded incidents ordered newest-first. - /// - /// The incidents ordered from newest to oldest. - IReadOnlyList GetIncidents(); - } -} diff --git a/BinDays.Api/Incidents/InMemoryIncidentStore.cs b/BinDays.Api/Incidents/InMemoryIncidentStore.cs deleted file mode 100644 index fd4fe0da..00000000 --- a/BinDays.Api/Incidents/InMemoryIncidentStore.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System; - using System.Collections.Generic; - using System.Linq; - - /// - /// In-memory fallback for development environments where Redis is not configured. - /// - internal sealed class InMemoryIncidentStore : IIncidentStore - { - private readonly object _lock = new(); - private readonly List _records = []; - - /// - public void RecordIncident(IncidentRecord incident) - { - ArgumentNullException.ThrowIfNull(incident); - - lock (_lock) - { - _records.Add(incident); - } - } - - /// - public IReadOnlyList GetIncidents() - { - lock (_lock) - { - return [.. _records.OrderByDescending(record => record.OccurredUtc)]; - } - } - } -} diff --git a/BinDays.Api/Incidents/IncidentCategory.cs b/BinDays.Api/Incidents/IncidentCategory.cs deleted file mode 100644 index b52c8435..00000000 --- a/BinDays.Api/Incidents/IncidentCategory.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System.Text.Json.Serialization; - - /// - /// High-level category assigned to incidents recorded for collectors. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum IncidentCategory - { - /// - /// An unexpected failure occurred while calling a council/collector upstream. - /// - CollectorFailure, - - /// - /// The platform encountered an infrastructure or configuration problem. - /// - SystemFailure, - - /// - /// The collector API contract appears to have changed unexpectedly. - /// - IntegrationChanged, - } -} diff --git a/BinDays.Api/Incidents/IncidentOperation.cs b/BinDays.Api/Incidents/IncidentOperation.cs deleted file mode 100644 index 4d55aba8..00000000 --- a/BinDays.Api/Incidents/IncidentOperation.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System.Text.Json.Serialization; - - /// - /// Indicates which collector operation was being performed when an incident occurred. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public enum IncidentOperation - { - /// - /// Incident occurred during the collector lookup flow. - /// - GetCollector, - - /// - /// Incident occurred while retrieving addresses. - /// - GetAddresses, - - /// - /// Incident occurred while retrieving bin days. - /// - GetBinDays, - } -} diff --git a/BinDays.Api/Incidents/IncidentRecord.cs b/BinDays.Api/Incidents/IncidentRecord.cs deleted file mode 100644 index 171e9526..00000000 --- a/BinDays.Api/Incidents/IncidentRecord.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using System; - using System.Text.Json.Serialization; - - /// - /// Value object persisted for each incident. - /// - public sealed class IncidentRecord - { - /// - /// Gets or sets the incident identifier. - /// - public Guid IncidentId { get; set; } - - /// - /// Gets or sets the collector gov.uk identifier associated with this incident. - /// - public string GovUkId { get; set; } = string.Empty; - - /// - /// Gets or sets the timestamp (UTC) when the incident occurred. - /// - public DateTime OccurredUtc { get; set; } - - /// - /// Gets or sets the incident category. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public IncidentCategory Category { get; set; } - - /// - /// Gets or sets the operation that was being executed. - /// - [JsonConverter(typeof(JsonStringEnumConverter))] - public IncidentOperation Operation { get; set; } - - /// - /// Gets or sets the hashed signature of the error message/stack trace. - /// - public string MessageHash { get; set; } = string.Empty; - - /// - /// Gets or sets the name of the exception type that triggered the incident. - /// - public string ExceptionType { get; set; } = string.Empty; - } -} diff --git a/BinDays.Api/Incidents/RedisIncidentStore.cs b/BinDays.Api/Incidents/RedisIncidentStore.cs deleted file mode 100644 index 78a4969d..00000000 --- a/BinDays.Api/Incidents/RedisIncidentStore.cs +++ /dev/null @@ -1,80 +0,0 @@ -namespace BinDays.Api.Incidents -{ - using StackExchange.Redis; - using System; - using System.Collections.Generic; - using System.Text.Json; - using System.Text.Json.Serialization; - - /// - /// Redis-backed implementation of . - /// - internal sealed class RedisIncidentStore : IIncidentStore - { - private const string IndexKey = "health:incidents"; - private static readonly TimeSpan RetentionWindow = TimeSpan.FromDays(90); - private static readonly JsonSerializerOptions SerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = { new JsonStringEnumConverter() }, - }; - - private readonly IConnectionMultiplexer _connectionMultiplexer; - - /// - /// Initializes a new instance of the class. - /// - /// The Redis connection. - public RedisIncidentStore(IConnectionMultiplexer multiplexer) - { - _connectionMultiplexer = multiplexer; - } - - /// - public void RecordIncident(IncidentRecord incident) - { - ArgumentNullException.ThrowIfNull(incident); - - var db = _connectionMultiplexer.GetDatabase(); - var payload = JsonSerializer.Serialize(incident, SerializerOptions); - - var cutoffScore = ToScore(DateTime.UtcNow - RetentionWindow); - - var transaction = db.CreateTransaction(); - _ = transaction.SortedSetRemoveRangeByScoreAsync(IndexKey, double.NegativeInfinity, cutoffScore); - _ = transaction.SortedSetAddAsync(IndexKey, payload, ToScore(incident.OccurredUtc)); - transaction.Execute(); - } - - /// - public IReadOnlyList GetIncidents() - { - var db = _connectionMultiplexer.GetDatabase(); - var entries = db.SortedSetRangeByScore(IndexKey, order: Order.Descending); - - if (entries.Length == 0) - { - return []; - } - - var incidents = entries - .Where(e => e.HasValue) - .Select(e => JsonSerializer.Deserialize(e!, SerializerOptions)) - .OfType() - .ToList(); - - return incidents; - } - - /// - /// Converts a UTC timestamp to a sorted-set score. - /// - /// The UTC timestamp. - /// The numeric score. - private static double ToScore(DateTime utcDateTime) - { - var utc = DateTime.SpecifyKind(utcDateTime, DateTimeKind.Utc); - return new DateTimeOffset(utc).ToUnixTimeMilliseconds(); - } - } -} diff --git a/BinDays.Api/Program.cs b/BinDays.Api/Program.cs index 77089eb0..ab4c0d66 100644 --- a/BinDays.Api/Program.cs +++ b/BinDays.Api/Program.cs @@ -1,4 +1,3 @@ -using BinDays.Api.Incidents; using StackExchange.Redis; var builder = WebApplication.CreateBuilder(args); @@ -11,13 +10,12 @@ builder.Services.AddControllers(); -// Add caching for responses and incidents, either in-memory or Redis +// Add caching for responses, either in-memory or Redis var redis = builder.Configuration.GetValue("Redis"); if (!string.IsNullOrEmpty(redis)) { var multiplexer = ConnectionMultiplexer.Connect(redis); builder.Services.AddSingleton(multiplexer); - builder.Services.AddSingleton(); builder.Services.AddStackExchangeRedisCache(options => { @@ -27,7 +25,6 @@ else { builder.Services.AddDistributedMemoryCache(); - builder.Services.AddSingleton(); } // Health check for monitoring