diff --git a/code/Executable/Controllers/EventsController.cs b/code/Executable/Controllers/EventsController.cs new file mode 100644 index 00000000..72948b6c --- /dev/null +++ b/code/Executable/Controllers/EventsController.cs @@ -0,0 +1,144 @@ +using Infrastructure.Events; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Executable.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class EventsController : ControllerBase +{ + private readonly EventDbContext _context; + + public EventsController(EventDbContext context) + { + _context = context; + } + + /// + /// Gets recent events + /// + [HttpGet] + public async Task>> GetEvents( + [FromQuery] int count = 100, + [FromQuery] string? severity = null, + [FromQuery] string? eventType = null, + [FromQuery] string? source = null) + { + var query = _context.Events.AsQueryable(); + + // Apply filters + if (!string.IsNullOrWhiteSpace(severity)) + query = query.Where(e => e.Severity == severity); + + if (!string.IsNullOrWhiteSpace(eventType)) + query = query.Where(e => e.EventType == eventType); + + if (!string.IsNullOrWhiteSpace(source)) + query = query.Where(e => e.Source.Contains(source)); + + // Order and limit + var events = await query + .OrderByDescending(e => e.Timestamp) + .Take(Math.Min(count, 1000)) // Cap at 1000 + .ToListAsync(); + + return Ok(events); + } + + /// + /// Gets a specific event by ID + /// + [HttpGet("{id}")] + public async Task> GetEvent(string id) + { + var eventEntity = await _context.Events.FindAsync(id); + + if (eventEntity == null) + return NotFound(); + + return Ok(eventEntity); + } + + /// + /// Gets events by correlation ID + /// + [HttpGet("correlation/{correlationId}")] + public async Task>> GetEventsByCorrelation(string correlationId) + { + var events = await _context.Events + .Where(e => e.CorrelationId == correlationId) + .OrderBy(e => e.Timestamp) + .ToListAsync(); + + return Ok(events); + } + + /// + /// Gets event statistics + /// + [HttpGet("stats")] + public async Task> GetEventStats() + { + var stats = new + { + TotalEvents = await _context.Events.CountAsync(), + EventsBySeverity = await _context.Events + .GroupBy(e => e.Severity) + .Select(g => new { Severity = g.Key, Count = g.Count() }) + .ToListAsync(), + EventsByType = await _context.Events + .GroupBy(e => e.EventType) + .Select(g => new { EventType = g.Key, Count = g.Count() }) + .OrderByDescending(x => x.Count) + .Take(10) + .ToListAsync(), + RecentEventsCount = await _context.Events + .Where(e => e.Timestamp > DateTime.UtcNow.AddHours(-24)) + .CountAsync() + }; + + return Ok(stats); + } + + /// + /// Manually triggers cleanup of old events + /// + [HttpPost("cleanup")] + public async Task> CleanupOldEvents([FromQuery] int retentionDays = 30) + { + var removedCount = await _context.CleanupOldEventsAsync(retentionDays); + + return Ok(new { RemovedCount = removedCount, RetentionDays = retentionDays }); + } + + /// + /// Gets unique event sources + /// + [HttpGet("sources")] + public async Task>> GetEventSources() + { + var sources = await _context.Events + .Select(e => e.Source) + .Distinct() + .OrderBy(s => s) + .ToListAsync(); + + return Ok(sources); + } + + /// + /// Gets unique event types + /// + [HttpGet("types")] + public async Task>> GetEventTypes() + { + var types = await _context.Events + .Select(e => e.EventType) + .Distinct() + .OrderBy(t => t) + .ToListAsync(); + + return Ok(types); + } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/Event.cs b/code/Infrastructure/Events/Event.cs new file mode 100644 index 00000000..b5009c05 --- /dev/null +++ b/code/Infrastructure/Events/Event.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Events; + +/// +/// Represents an event in the system +/// +[Index(nameof(Timestamp), IsDescending = new[] { true })] +[Index(nameof(EventType))] +[Index(nameof(Severity))] +[Index(nameof(Source))] +public class Event +{ + [Key] + public string Id { get; set; } = Guid.NewGuid().ToString(); + + [Required] + public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + [Required] + [MaxLength(100)] + public string EventType { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] + public string Source { get; set; } = string.Empty; + + [Required] + [MaxLength(1000)] + public string Message { get; set; } = string.Empty; + + /// + /// JSON data associated with the event + /// + public string? Data { get; set; } + + [Required] + [MaxLength(20)] + public string Severity { get; set; } = "Info"; // Info, Warning, Error, Critical + + /// + /// Optional correlation ID to link related events + /// + [MaxLength(50)] + public string? CorrelationId { get; set; } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/EventCleanupService.cs b/code/Infrastructure/Events/EventCleanupService.cs new file mode 100644 index 00000000..4ff88f3c --- /dev/null +++ b/code/Infrastructure/Events/EventCleanupService.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Events; + +/// +/// Background service that periodically cleans up old events +/// +public class EventCleanupService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(4); // Run every 4 hours + private readonly int _retentionDays = 30; // Keep events for 30 days + + public EventCleanupService(IServiceProvider serviceProvider, ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Event cleanup service started. Interval: {interval}, Retention: {retention} days", + _cleanupInterval, _retentionDays); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await Task.Delay(_cleanupInterval, stoppingToken); + + if (stoppingToken.IsCancellationRequested) + break; + + await PerformCleanupAsync(); + } + catch (OperationCanceledException) + { + // Service is stopping + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during event cleanup"); + } + } + + _logger.LogInformation("Event cleanup service stopped"); + } + + private async Task PerformCleanupAsync() + { + try + { + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var cutoffDate = DateTime.UtcNow.AddDays(-_retentionDays); + var oldEventsQuery = context.Events.Where(e => e.Timestamp < cutoffDate); + var count = await oldEventsQuery.CountAsync(); + + if (count > 0) + { + _logger.LogInformation("Cleaning up {count} events older than {cutoffDate:yyyy-MM-dd}", count, cutoffDate); + + // Remove in batches to avoid large transactions + const int batchSize = 1000; + var totalRemoved = 0; + + while (true) + { + var batch = await oldEventsQuery.Take(batchSize).ToListAsync(); + if (batch.Count == 0) + break; + + context.Events.RemoveRange(batch); + await context.SaveChangesAsync(); + totalRemoved += batch.Count; + + _logger.LogTrace("Removed batch of {batchCount} events, total removed: {totalRemoved}", batch.Count, totalRemoved); + } + + _logger.LogInformation("Event cleanup completed. Removed {totalRemoved} old events", totalRemoved); + } + else + { + _logger.LogTrace("No old events to clean up"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to perform event cleanup"); + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Event cleanup service stopping..."); + await base.StopAsync(cancellationToken); + } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/EventDbContext.cs b/code/Infrastructure/Events/EventDbContext.cs new file mode 100644 index 00000000..46b8f9f1 --- /dev/null +++ b/code/Infrastructure/Events/EventDbContext.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Infrastructure.Configuration; + +namespace Infrastructure.Events; + +/// +/// Database context for events +/// +public class EventDbContext : DbContext +{ + public DbSet Events { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + if (!optionsBuilder.IsConfigured) + { + var dbPath = Path.Combine(ConfigurationPathProvider.GetSettingsPath(), "events.db"); + optionsBuilder.UseSqlite($"Data Source={dbPath}"); + } + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Additional configuration if needed + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.Property(e => e.Timestamp).IsRequired(); + entity.Property(e => e.EventType).IsRequired().HasMaxLength(100); + entity.Property(e => e.Source).IsRequired().HasMaxLength(100); + entity.Property(e => e.Message).IsRequired().HasMaxLength(1000); + entity.Property(e => e.Severity).IsRequired().HasMaxLength(20); + entity.Property(e => e.CorrelationId).HasMaxLength(50); + }); + } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/EventExtensions.cs b/code/Infrastructure/Events/EventExtensions.cs new file mode 100644 index 00000000..8ecd8e6b --- /dev/null +++ b/code/Infrastructure/Events/EventExtensions.cs @@ -0,0 +1,170 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Events; + +/// +/// Extension methods for easy event publishing +/// +public static class EventExtensions +{ + /// + /// Publishes an information event + /// + public static async Task PublishInfoEventAsync(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null) + { + await PublishEventAsync(context, "Information", source, message, "Info", data, correlationId); + } + + /// + /// Publishes a warning event + /// + public static async Task PublishWarningEventAsync(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null) + { + await PublishEventAsync(context, "Warning", source, message, "Warning", data, correlationId); + } + + /// + /// Publishes an error event + /// + public static async Task PublishErrorEventAsync(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null) + { + await PublishEventAsync(context, "Error", source, message, "Error", data, correlationId); + } + + /// + /// Publishes a custom event + /// + public static async Task PublishEventAsync(this EventDbContext context, string eventType, string source, string message, string severity = "Info", object? data = null, string? correlationId = null) + { + var eventEntity = new Event + { + EventType = eventType, + Source = source, + Message = message, + Severity = severity, + Data = data != null ? JsonSerializer.Serialize(data) : null, + CorrelationId = correlationId + }; + + context.Events.Add(eventEntity); + await context.SaveChangesAsync(); + } + + /// + /// Publishes an information event synchronously + /// + public static void PublishInfoEvent(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null) + { + PublishEvent(context, "Information", source, message, "Info", data, correlationId); + } + + /// + /// Publishes a warning event synchronously + /// + public static void PublishWarningEvent(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null) + { + PublishEvent(context, "Warning", source, message, "Warning", data, correlationId); + } + + /// + /// Publishes an error event synchronously + /// + public static void PublishErrorEvent(this EventDbContext context, string source, string message, object? data = null, string? correlationId = null) + { + PublishEvent(context, "Error", source, message, "Error", data, correlationId); + } + + /// + /// Publishes a custom event synchronously + /// + public static void PublishEvent(this EventDbContext context, string eventType, string source, string message, string severity = "Info", object? data = null, string? correlationId = null) + { + var eventEntity = new Event + { + EventType = eventType, + Source = source, + Message = message, + Severity = severity, + Data = data != null ? JsonSerializer.Serialize(data) : null, + CorrelationId = correlationId + }; + + context.Events.Add(eventEntity); + context.SaveChanges(); + } + + /// + /// Gets recent events + /// + public static async Task> GetRecentEventsAsync(this EventDbContext context, int count = 100) + { + return await context.Events + .OrderByDescending(e => e.Timestamp) + .Take(count) + .ToListAsync(); + } + + /// + /// Gets events by type + /// + public static async Task> GetEventsByTypeAsync(this EventDbContext context, string eventType, int count = 100) + { + return await context.Events + .Where(e => e.EventType == eventType) + .OrderByDescending(e => e.Timestamp) + .Take(count) + .ToListAsync(); + } + + /// + /// Gets events by severity + /// + public static async Task> GetEventsBySeverityAsync(this EventDbContext context, string severity, int count = 100) + { + return await context.Events + .Where(e => e.Severity == severity) + .OrderByDescending(e => e.Timestamp) + .Take(count) + .ToListAsync(); + } + + /// + /// Cleans up events older than the specified number of days + /// + public static async Task CleanupOldEventsAsync(this EventDbContext context, int retentionDays = 30) + { + var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); + var oldEvents = context.Events.Where(e => e.Timestamp < cutoffDate); + var count = await oldEvents.CountAsync(); + + if (count > 0) + { + context.Events.RemoveRange(oldEvents); + await context.SaveChangesAsync(); + } + + return count; + } +} + +/// +/// Service collection extensions for event system +/// +public static class EventServiceExtensions +{ + /// + /// Adds event system with SQLite database + /// + public static IServiceCollection AddEventSystem(this IServiceCollection services) + { + services.AddDbContext(); + services.AddScoped(); + services.AddSingleton(); + services.AddHostedService(); + services.AddSignalR(); + return services; + } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/EventHub.cs b/code/Infrastructure/Events/EventHub.cs new file mode 100644 index 00000000..f041e213 --- /dev/null +++ b/code/Infrastructure/Events/EventHub.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Events; + +/// +/// SignalR hub for real-time event delivery +/// +public class EventHub : Hub +{ + private readonly EventDbContext _context; + private readonly ILogger _logger; + + public EventHub(EventDbContext context, ILogger logger) + { + _context = context; + _logger = logger; + } + + /// + /// Client connects and subscribes to all events + /// + public async Task JoinEventsGroup() + { + await Groups.AddToGroupAsync(Context.ConnectionId, "Events"); + _logger.LogTrace("Client {connectionId} joined Events group", Context.ConnectionId); + } + + /// + /// Client unsubscribes from all events + /// + public async Task LeaveEventsGroup() + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, "Events"); + _logger.LogTrace("Client {connectionId} left Events group", Context.ConnectionId); + } + + /// + /// Client subscribes to specific severity level + /// + public async Task JoinSeverityGroup(string severity) + { + if (IsValidSeverity(severity)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"Events_{severity}"); + _logger.LogTrace("Client {connectionId} joined severity group {severity}", Context.ConnectionId, severity); + } + } + + /// + /// Client unsubscribes from specific severity level + /// + public async Task LeaveSeverityGroup(string severity) + { + if (IsValidSeverity(severity)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"Events_{severity}"); + _logger.LogTrace("Client {connectionId} left severity group {severity}", Context.ConnectionId, severity); + } + } + + /// + /// Client subscribes to specific event type + /// + public async Task JoinTypeGroup(string eventType) + { + if (!string.IsNullOrWhiteSpace(eventType)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"Events_{eventType}"); + _logger.LogTrace("Client {connectionId} joined type group {eventType}", Context.ConnectionId, eventType); + } + } + + /// + /// Client unsubscribes from specific event type + /// + public async Task LeaveTypeGroup(string eventType) + { + if (!string.IsNullOrWhiteSpace(eventType)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"Events_{eventType}"); + _logger.LogTrace("Client {connectionId} left type group {eventType}", Context.ConnectionId, eventType); + } + } + + /// + /// Client requests recent events (for initial load) + /// + public async Task GetRecentEvents(int count = 50) + { + try + { + var events = await _context.Events + .OrderByDescending(e => e.Timestamp) + .Take(Math.Min(count, 100)) // Cap at 100 + .ToListAsync(); + + await Clients.Caller.SendAsync("RecentEventsReceived", events); + _logger.LogTrace("Sent {count} recent events to client {connectionId}", events.Count, Context.ConnectionId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send recent events to client {connectionId}", Context.ConnectionId); + } + } + + /// + /// Client connection established + /// + public override async Task OnConnectedAsync() + { + _logger.LogTrace("Client {connectionId} connected to EventHub", Context.ConnectionId); + await base.OnConnectedAsync(); + } + + /// + /// Client disconnected + /// + public override async Task OnDisconnectedAsync(Exception? exception) + { + _logger.LogTrace("Client {connectionId} disconnected from EventHub", Context.ConnectionId); + await base.OnDisconnectedAsync(exception); + } + + private static bool IsValidSeverity(string severity) + { + return severity is "Info" or "Warning" or "Error" or "Critical"; + } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/EventPublisher.cs b/code/Infrastructure/Events/EventPublisher.cs new file mode 100644 index 00000000..cfe6088f --- /dev/null +++ b/code/Infrastructure/Events/EventPublisher.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace Infrastructure.Events; + +/// +/// Service for publishing events to database and SignalR hub +/// +public class EventPublisher +{ + private readonly EventDbContext _context; + private readonly IHubContext _hubContext; + private readonly ILogger _logger; + + public EventPublisher( + EventDbContext context, + IHubContext hubContext, + ILogger logger) + { + _context = context; + _hubContext = hubContext; + _logger = logger; + } + + /// + /// Publishes an event to database and SignalR clients + /// + public async Task PublishAsync(string eventType, string source, string message, string severity = "Info", object? data = null, string? correlationId = null) + { + var eventEntity = new Event + { + EventType = eventType, + Source = source, + Message = message, + Severity = severity, + Data = data != null ? JsonSerializer.Serialize(data) : null, + CorrelationId = correlationId + }; + + // Save to database + _context.Events.Add(eventEntity); + await _context.SaveChangesAsync(); + + // Send to SignalR clients + await NotifyClientsAsync(eventEntity); + + _logger.LogTrace("Published event: {eventType} from {source}", eventType, source); + } + + /// + /// Publishes an info event + /// + public async Task PublishInfoAsync(string source, string message, object? data = null, string? correlationId = null) + { + await PublishAsync("Information", source, message, "Info", data, correlationId); + } + + /// + /// Publishes a warning event + /// + public async Task PublishWarningAsync(string source, string message, object? data = null, string? correlationId = null) + { + await PublishAsync("Warning", source, message, "Warning", data, correlationId); + } + + /// + /// Publishes an error event + /// + public async Task PublishErrorAsync(string source, string message, object? data = null, string? correlationId = null) + { + await PublishAsync("Error", source, message, "Error", data, correlationId); + } + + /// + /// Publishes a notification-related event (for HTTP notifications to Notifiarr/Apprise) + /// + public async Task PublishNotificationEventAsync(string provider, string message, bool success, object? data = null, string? correlationId = null) + { + var eventType = success ? "NotificationSent" : "NotificationFailed"; + var severity = success ? "Info" : "Warning"; + + await PublishAsync(eventType, $"NotificationService.{provider}", message, severity, data, correlationId); + } + + /// + /// Publishes an HTTP call event (for external API calls) + /// + public async Task PublishHttpCallEventAsync(string endpoint, string method, int statusCode, TimeSpan duration, object? data = null, string? correlationId = null) + { + var success = statusCode >= 200 && statusCode < 300; + var eventType = success ? "HttpCallSuccess" : "HttpCallFailed"; + var severity = success ? "Info" : "Warning"; + var message = $"{method} {endpoint} - {statusCode} ({duration.TotalMilliseconds}ms)"; + + await PublishAsync(eventType, "HttpClient", message, severity, data, correlationId); + } + + private async Task NotifyClientsAsync(Event eventEntity) + { + try + { + // Send to all clients in Events group + await _hubContext.Clients.Group("Events").SendAsync("EventReceived", eventEntity); + + // Send to severity-specific groups + await _hubContext.Clients.Group($"Events_{eventEntity.Severity}") + .SendAsync("EventReceived", eventEntity); + + // Send to type-specific groups + await _hubContext.Clients.Group($"Events_{eventEntity.EventType}") + .SendAsync("EventReceived", eventEntity); + + _logger.LogTrace("Sent event {eventId} to SignalR clients", eventEntity.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send event {eventId} to SignalR clients", eventEntity.Id); + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Events/NotificationEventWrapper.cs b/code/Infrastructure/Events/NotificationEventWrapper.cs new file mode 100644 index 00000000..fe729803 --- /dev/null +++ b/code/Infrastructure/Events/NotificationEventWrapper.cs @@ -0,0 +1,120 @@ +using Infrastructure.Verticals.Notifications; +using Infrastructure.Verticals.Notifications.Models; +using Microsoft.Extensions.Logging; +using System.Diagnostics; + +namespace Infrastructure.Events; + +/// +/// Wrapper around NotificationService that publishes events for all notification calls +/// +public class NotificationEventWrapper +{ + private readonly NotificationService _notificationService; + private readonly EventPublisher _eventPublisher; + private readonly ILogger _logger; + + public NotificationEventWrapper( + NotificationService notificationService, + EventPublisher eventPublisher, + ILogger logger) + { + _notificationService = notificationService; + _eventPublisher = eventPublisher; + _logger = logger; + } + + public async Task Notify(FailedImportStrikeNotification notification) + { + await NotifyWithEventLogging("FailedImportStrike", notification, + async () => await _notificationService.Notify(notification)); + } + + public async Task Notify(StalledStrikeNotification notification) + { + await NotifyWithEventLogging("StalledStrike", notification, + async () => await _notificationService.Notify(notification)); + } + + public async Task Notify(SlowStrikeNotification notification) + { + await NotifyWithEventLogging("SlowStrike", notification, + async () => await _notificationService.Notify(notification)); + } + + public async Task Notify(QueueItemDeletedNotification notification) + { + await NotifyWithEventLogging("QueueItemDeleted", notification, + async () => await _notificationService.Notify(notification)); + } + + public async Task Notify(DownloadCleanedNotification notification) + { + await NotifyWithEventLogging("DownloadCleaned", notification, + async () => await _notificationService.Notify(notification)); + } + + public async Task Notify(CategoryChangedNotification notification) + { + await NotifyWithEventLogging("CategoryChanged", notification, + async () => await _notificationService.Notify(notification)); + } + + private async Task NotifyWithEventLogging(string notificationType, T notification, Func notifyAction) + where T : class + { + var correlationId = Guid.NewGuid().ToString("N")[..8]; + var stopwatch = Stopwatch.StartNew(); + + try + { + // Log notification attempt + await _eventPublisher.PublishInfoAsync( + source: "NotificationService", + message: $"Sending {notificationType} notification", + data: new { NotificationType = notificationType, Notification = notification }, + correlationId: correlationId); + + // Execute the notification + await notifyAction(); + + stopwatch.Stop(); + + // Log successful notification + await _eventPublisher.PublishInfoAsync( + source: "NotificationService", + message: $"{notificationType} notification sent successfully", + data: new { + NotificationType = notificationType, + Duration = stopwatch.ElapsedMilliseconds, + Success = true + }, + correlationId: correlationId); + + _logger.LogInformation("Successfully sent {notificationType} notification in {duration}ms", + notificationType, stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + + // Log failed notification + await _eventPublisher.PublishErrorAsync( + source: "NotificationService", + message: $"Failed to send {notificationType} notification: {ex.Message}", + data: new { + NotificationType = notificationType, + Duration = stopwatch.ElapsedMilliseconds, + Success = false, + Error = ex.Message, + StackTrace = ex.StackTrace + }, + correlationId: correlationId); + + _logger.LogError(ex, "Failed to send {notificationType} notification after {duration}ms", + notificationType, stopwatch.ElapsedMilliseconds); + + throw; // Re-throw to maintain original behavior + } + } +} \ No newline at end of file diff --git a/code/Infrastructure/Infrastructure.csproj b/code/Infrastructure/Infrastructure.csproj index c38775c4..7b801276 100644 --- a/code/Infrastructure/Infrastructure.csproj +++ b/code/Infrastructure/Infrastructure.csproj @@ -17,6 +17,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive +