diff --git a/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs b/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs index af5e473..de92a43 100644 --- a/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs +++ b/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs @@ -12,7 +12,7 @@ namespace BCards.IntegrationTests.Helpers; public static class AuthenticationHelper { - public static async Task CreateAuthenticatedClientAsync( + public static Task CreateAuthenticatedClientAsync( WebApplicationFactory factory, User testUser) { @@ -34,7 +34,7 @@ public static class AuthenticationHelper client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email); client.DefaultRequestHeaders.Add("TestUserName", testUser.Name); - return client; + return Task.FromResult(client); } public static ClaimsPrincipal CreateTestClaimsPrincipal(User user) diff --git a/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs b/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs index 62e6c98..3e2649a 100644 --- a/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs +++ b/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs @@ -161,9 +161,9 @@ public class PuppeteerTestHelper : IAsyncDisposable await Page.ScreenshotAsync(fileName); } - public async Task GetCurrentUrlAsync() + public Task GetCurrentUrlAsync() { - return Page.Url; + return Task.FromResult(Page.Url); } public async Task> GetAllElementTextsAsync(string selector) diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj index 3395dd4..1819726 100644 --- a/src/BCards.Web/BCards.Web.csproj +++ b/src/BCards.Web/BCards.Web.csproj @@ -20,6 +20,15 @@ + + + + + + + + + diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index 0d26f71..ea38e79 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using System.Security.Claims; +using System.Text.Json; namespace BCards.Web.Controllers; @@ -197,6 +198,11 @@ public class AdminController : Controller ModelState.Remove(x => x.TwitterUrl); ModelState.Remove(x => x.WhatsAppNumber); + _logger.LogInformation($"ManagePage POST: IsNewPage={model.IsNewPage}, DisplayName={model.DisplayName}, Category={model.Category}, Links={model.Links?.Count ?? 0}"); + + //Logar modelstate em information + _logger.LogInformation($"ModelState: {JsonSerializer.Serialize(ModelState)}"); + // Processar upload de imagem se fornecida if (model.ProfileImageFile != null && model.ProfileImageFile.Length > 0) { diff --git a/src/BCards.Web/Controllers/HealthController.cs b/src/BCards.Web/Controllers/HealthController.cs new file mode 100644 index 0000000..f1805a7 --- /dev/null +++ b/src/BCards.Web/Controllers/HealthController.cs @@ -0,0 +1,278 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Diagnostics; +using System.Text.Json; + +namespace BCards.Web.Controllers; + +[ApiController] +[Route("health")] +public class HealthController : ControllerBase +{ + private readonly HealthCheckService _healthCheckService; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private static readonly DateTime _startTime = DateTime.UtcNow; + + public HealthController(HealthCheckService healthCheckService, ILogger logger, IConfiguration configuration) + { + _healthCheckService = healthCheckService; + _logger = logger; + _configuration = configuration; + } + + /// + /// Health check simples - retorna apenas status geral + /// + [HttpGet] + public async Task GetHealth() + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(); + stopwatch.Stop(); + + var response = new + { + status = healthReport.Status.ToString().ToLower(), + timestamp = DateTime.UtcNow, + duration = $"{stopwatch.ElapsedMilliseconds}ms" + }; + + _logger.LogInformation("Simple health check completed: {Status} in {Duration}ms", + response.status, stopwatch.ElapsedMilliseconds); + + return healthReport.Status == HealthStatus.Healthy + ? Ok(response) + : StatusCode(503, response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); + + return StatusCode(503, new + { + status = "unhealthy", + timestamp = DateTime.UtcNow, + duration = $"{stopwatch.ElapsedMilliseconds}ms", + error = ex.Message + }); + } + } + + /// + /// Health check detalhado - formato completo com métricas + /// + [HttpGet("detailed")] + public async Task GetDetailedHealth() + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(); + stopwatch.Stop(); + + var checks = new Dictionary(); + + foreach (var entry in healthReport.Entries) + { + checks[entry.Key] = new + { + status = entry.Value.Status.ToString().ToLower(), + duration = entry.Value.Duration.TotalMilliseconds + "ms", + description = entry.Value.Description, + data = entry.Value.Data, + exception = entry.Value.Exception?.Message + }; + } + + var uptime = DateTime.UtcNow - _startTime; + + var response = new + { + applicationName = _configuration["ApplicationName"] ?? "BCards", + status = healthReport.Status.ToString().ToLower(), + timestamp = DateTime.UtcNow, + uptime = FormatUptime(uptime), + totalDuration = $"{stopwatch.ElapsedMilliseconds}ms", + checks = checks, + version = "1.0.0", + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" + }; + + _logger.LogInformation("Detailed health check completed: {Status} in {Duration}ms - {HealthyCount}/{TotalCount} services healthy", + response.status, stopwatch.ElapsedMilliseconds, + healthReport.Entries.Count(e => e.Value.Status == HealthStatus.Healthy), + healthReport.Entries.Count); + + return healthReport.Status == HealthStatus.Unhealthy + ? StatusCode(503, response) + : Ok(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Detailed health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); + + return StatusCode(503, new + { + applicationName = "BCards", + status = "unhealthy", + timestamp = DateTime.UtcNow, + uptime = FormatUptime(DateTime.UtcNow - _startTime), + totalDuration = $"{stopwatch.ElapsedMilliseconds}ms", + error = ex.Message, + version = "1.0.0", + environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production" + }); + } + } + + /// + /// Health check para Uptime Kuma - formato específico + /// + [HttpGet("uptime-kuma")] + public async Task GetUptimeKumaHealth() + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(); + stopwatch.Stop(); + + var isHealthy = healthReport.Status == HealthStatus.Healthy; + var response = new + { + status = isHealthy ? "up" : "down", + message = isHealthy ? "All services operational" : $"Issues detected: {healthReport.Status}", + timestamp = DateTime.UtcNow.ToString("O"), + responseTime = stopwatch.ElapsedMilliseconds, + services = healthReport.Entries.ToDictionary( + e => e.Key, + e => new { + status = e.Value.Status.ToString().ToLower(), + responseTime = e.Value.Duration.TotalMilliseconds + } + ) + }; + + _logger.LogInformation("Uptime Kuma health check: {Status} in {Duration}ms", + response.status, stopwatch.ElapsedMilliseconds); + + return Ok(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Uptime Kuma health check failed after {Duration}ms", stopwatch.ElapsedMilliseconds); + + return Ok(new + { + status = "down", + message = $"Health check failed: {ex.Message}", + timestamp = DateTime.UtcNow.ToString("O"), + responseTime = stopwatch.ElapsedMilliseconds + }); + } + } + + /// + /// Health checks específicos por serviço + /// + [HttpGet("mongodb")] + public async Task GetMongoDbHealth() + { + return await GetSpecificServiceHealth("mongodb"); + } + + [HttpGet("stripe")] + public async Task GetStripeHealth() + { + return await GetSpecificServiceHealth("stripe"); + } + + [HttpGet("sendgrid")] + public async Task GetSendGridHealth() + { + return await GetSpecificServiceHealth("sendgrid"); + } + + [HttpGet("external")] + public async Task GetExternalServicesHealth() + { + return await GetSpecificServiceHealth("external_services"); + } + + [HttpGet("resources")] + public async Task GetSystemResourcesHealth() + { + return await GetSpecificServiceHealth("resources"); + } + + private async Task GetSpecificServiceHealth(string serviceName) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + var healthReport = await _healthCheckService.CheckHealthAsync(check => check.Name == serviceName); + stopwatch.Stop(); + + if (!healthReport.Entries.Any()) + { + return NotFound(new { error = $"Service '{serviceName}' not found" }); + } + + var entry = healthReport.Entries.First().Value; + + var response = new + { + service = serviceName, + status = entry.Status.ToString().ToLower(), + timestamp = DateTime.UtcNow, + duration = $"{entry.Duration.TotalMilliseconds}ms", + description = entry.Description, + data = entry.Data, + exception = entry.Exception?.Message + }; + + _logger.LogInformation("Service {ServiceName} health check: {Status} in {Duration}ms", + serviceName, response.status, entry.Duration.TotalMilliseconds); + + return entry.Status == HealthStatus.Unhealthy + ? StatusCode(503, response) + : Ok(response); + } + catch (Exception ex) + { + stopwatch.Stop(); + _logger.LogError(ex, "Service {ServiceName} health check failed after {Duration}ms", + serviceName, stopwatch.ElapsedMilliseconds); + + return StatusCode(503, new + { + service = serviceName, + status = "unhealthy", + timestamp = DateTime.UtcNow, + duration = $"{stopwatch.ElapsedMilliseconds}ms", + error = ex.Message + }); + } + } + + private static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalHours >= 1) + return $"{uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalMinutes >= 1) + return $"{uptime.Minutes}m {uptime.Seconds}s"; + return $"{uptime.Seconds}s"; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/UserPageController.cs b/src/BCards.Web/Controllers/UserPageController.cs index fe6af62..4c98aa2 100644 --- a/src/BCards.Web/Controllers/UserPageController.cs +++ b/src/BCards.Web/Controllers/UserPageController.cs @@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Mvc; namespace BCards.Web.Controllers; -//[Route("[controller]")] public class UserPageController : Controller { private readonly IUserPageService _userPageService; @@ -28,9 +27,6 @@ public class UserPageController : Controller _moderationService = moderationService; } - //[Route("{category}/{slug}")] - //VOltar a linha abaixo em prod - //[ResponseCache(Duration = 300, VaryByQueryKeys = new[] { "category", "slug" })] public async Task Display(string category, string slug) { var userPage = await _userPageService.GetPageAsync(category, slug); diff --git a/src/BCards.Web/HealthChecks/ExternalServicesHealthCheck.cs b/src/BCards.Web/HealthChecks/ExternalServicesHealthCheck.cs new file mode 100644 index 0000000..95ed8d5 --- /dev/null +++ b/src/BCards.Web/HealthChecks/ExternalServicesHealthCheck.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class ExternalServicesHealthCheck : IHealthCheck +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ExternalServicesHealthCheck(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + var results = new Dictionary(); + var allHealthy = true; + var hasUnhealthy = false; + + try + { + // Lista de serviços externos para verificar + var services = new Dictionary + { + { "google_oauth", "https://accounts.google.com/.well-known/openid_configuration" }, + { "microsoft_oauth", "https://login.microsoftonline.com/common/v2.0/.well-known/openid_configuration" } + }; + + foreach (var service in services) + { + var serviceStopwatch = Stopwatch.StartNew(); + try + { + using var response = await _httpClient.GetAsync(service.Value, cancellationToken); + serviceStopwatch.Stop(); + + var serviceResult = new Dictionary + { + { "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" }, + { "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" }, + { "status_code", (int)response.StatusCode }, + { "url", service.Value } + }; + + results[service.Key] = serviceResult; + + if (!response.IsSuccessStatusCode) + { + allHealthy = false; + if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable || + response.StatusCode == System.Net.HttpStatusCode.InternalServerError) + { + hasUnhealthy = true; + } + } + + _logger.LogInformation("External service {Service} health check: {Status} in {Duration}ms", + service.Key, response.StatusCode, serviceStopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + serviceStopwatch.Stop(); + allHealthy = false; + hasUnhealthy = true; + + results[service.Key] = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{serviceStopwatch.ElapsedMilliseconds}ms" }, + { "error", ex.Message }, + { "url", service.Value } + }; + + _logger.LogError(ex, "External service {Service} health check failed", service.Key); + } + } + + stopwatch.Stop(); + var totalDuration = stopwatch.ElapsedMilliseconds; + + var data = new Dictionary + { + { "status", hasUnhealthy ? "unhealthy" : (allHealthy ? "healthy" : "degraded") }, + { "duration", $"{totalDuration}ms" }, + { "services", results }, + { "total_services", services.Count }, + { "healthy_services", results.Values.Count(r => ((Dictionary)r)["status"].ToString() == "healthy") } + }; + + if (hasUnhealthy) + { + return HealthCheckResult.Unhealthy("One or more external services are unhealthy", data: data); + } + + if (!allHealthy) + { + return HealthCheckResult.Degraded("Some external services have issues", data: data); + } + + return HealthCheckResult.Healthy($"All external services are responsive ({totalDuration}ms)", data: data); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "External services health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"External services check failed: {ex.Message}", ex, data); + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs b/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs new file mode 100644 index 0000000..4f53123 --- /dev/null +++ b/src/BCards.Web/HealthChecks/MongoDbHealthCheck.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using MongoDB.Driver; +using MongoDB.Bson; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class MongoDbHealthCheck : IHealthCheck +{ + private readonly IMongoDatabase _database; + private readonly ILogger _logger; + + public MongoDbHealthCheck(IMongoDatabase database, ILogger logger) + { + _database = database; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Executa ping no MongoDB + var command = new BsonDocument("ping", 1); + await _database.RunCommandAsync(command, cancellationToken: cancellationToken); + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "healthy" }, + { "duration", $"{duration}ms" }, + { "database", _database.DatabaseNamespace.DatabaseName }, + { "connection_state", "connected" }, + { "latency", duration } + }; + + // Status baseado na latência + if (duration > 5000) // > 5s + return HealthCheckResult.Unhealthy($"MongoDB response time too high: {duration}ms", data: data); + + if (duration > 2000) // > 2s + return HealthCheckResult.Degraded($"MongoDB response time elevated: {duration}ms", data: data); + + return HealthCheckResult.Healthy($"MongoDB is responsive ({duration}ms)", data: data); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "MongoDB health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "database", _database.DatabaseNamespace.DatabaseName }, + { "connection_state", "disconnected" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"MongoDB connection failed: {ex.Message}", ex, data); + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs b/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs new file mode 100644 index 0000000..9901ffc --- /dev/null +++ b/src/BCards.Web/HealthChecks/SendGridHealthCheck.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using SendGrid; +using SendGrid.Helpers.Mail; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class SendGridHealthCheck : IHealthCheck +{ + private readonly ISendGridClient _sendGridClient; + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + + public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger logger, IConfiguration configuration) + { + _sendGridClient = sendGridClient; + _logger = logger; + _configuration = configuration; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Testa a API do SendGrid fazendo uma validação de API key + // Usando endpoint de templates que não requer parâmetros específicos + var response = await _sendGridClient.RequestAsync( + method: SendGridClient.Method.GET, + urlPath: "templates", + queryParams: "{\"generations\":\"legacy,dynamic\",\"page_size\":1}", + cancellationToken: cancellationToken); + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + var apiKey = _configuration["SendGrid:ApiKey"]; + var apiKeyPrefix = string.IsNullOrEmpty(apiKey) ? "not_configured" : + apiKey.Substring(0, Math.Min(8, apiKey.Length)) + "..."; + + _logger.LogInformation("SendGrid health check completed with status {StatusCode} in {Duration}ms", + response.StatusCode, duration); + + var data = new Dictionary + { + { "status", response.IsSuccessStatusCode ? "healthy" : "unhealthy" }, + { "duration", $"{duration}ms" }, + { "status_code", (int)response.StatusCode }, + { "api_key_prefix", apiKeyPrefix }, + { "latency", duration } + }; + + // Verifica se a resposta foi bem-sucedida + if (response.IsSuccessStatusCode) + { + // Status baseado na latência + if (duration > 8000) // > 8s + return HealthCheckResult.Unhealthy($"SendGrid response time too high: {duration}ms", data: data); + + if (duration > 4000) // > 4s + return HealthCheckResult.Degraded($"SendGrid response time elevated: {duration}ms", data: data); + + return HealthCheckResult.Healthy($"SendGrid API is responsive ({duration}ms)", data: data); + } + else + { + data["error"] = $"HTTP {response.StatusCode}"; + data["response_body"] = response.Body; + + return HealthCheckResult.Unhealthy( + $"SendGrid API returned {response.StatusCode}", + data: data); + } + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "SendGrid health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data); + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/StripeHealthCheck.cs b/src/BCards.Web/HealthChecks/StripeHealthCheck.cs new file mode 100644 index 0000000..ae01f2f --- /dev/null +++ b/src/BCards.Web/HealthChecks/StripeHealthCheck.cs @@ -0,0 +1,94 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using BCards.Web.Configuration; +using Stripe; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class StripeHealthCheck : IHealthCheck +{ + private readonly StripeSettings _stripeSettings; + private readonly ILogger _logger; + + public StripeHealthCheck(IOptions stripeSettings, ILogger logger) + { + _stripeSettings = stripeSettings.Value; + _logger = logger; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Configura Stripe temporariamente para o teste + StripeConfiguration.ApiKey = _stripeSettings.SecretKey; + + // Testa conectividade listando produtos (limite 1 para ser rápido) + var productService = new ProductService(); + var options = new ProductListOptions { Limit = 1 }; + + await productService.ListAsync(options, cancellationToken: cancellationToken); + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogInformation("Stripe health check completed successfully in {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "healthy" }, + { "duration", $"{duration}ms" }, + { "api_key_prefix", _stripeSettings.SecretKey?.Substring(0, Math.Min(12, _stripeSettings.SecretKey.Length)) + "..." }, + { "latency", duration } + }; + + // Status baseado na latência + if (duration > 10000) // > 10s + return HealthCheckResult.Unhealthy($"Stripe response time too high: {duration}ms", data: data); + + if (duration > 5000) // > 5s + return HealthCheckResult.Degraded($"Stripe response time elevated: {duration}ms", data: data); + + return HealthCheckResult.Healthy($"Stripe API is responsive ({duration}ms)", data: data); + } + catch (StripeException stripeEx) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(stripeEx, "Stripe health check failed after {Duration}ms: {Error}", duration, stripeEx.Message); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", stripeEx.Message }, + { "error_code", stripeEx.StripeError?.Code ?? "unknown" }, + { "error_type", stripeEx.StripeError?.Type ?? "unknown" } + }; + + return HealthCheckResult.Unhealthy($"Stripe API error: {stripeEx.Message}", stripeEx, data); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "Stripe health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data); + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs b/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs new file mode 100644 index 0000000..d5ad880 --- /dev/null +++ b/src/BCards.Web/HealthChecks/SystemResourcesHealthCheck.cs @@ -0,0 +1,134 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using System.Diagnostics; + +namespace BCards.Web.HealthChecks; + +public class SystemResourcesHealthCheck : IHealthCheck +{ + private readonly ILogger _logger; + private static readonly DateTime _startTime = DateTime.UtcNow; + + public SystemResourcesHealthCheck(ILogger logger) + { + _logger = logger; + } + + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + + try + { + // Informações de memória + var totalMemory = GC.GetTotalMemory(false); + var workingSet = Environment.WorkingSet; + + // Informações do processo atual + using var currentProcess = Process.GetCurrentProcess(); + var cpuUsage = GetCpuUsage(currentProcess); + + // Uptime + var uptime = DateTime.UtcNow - _startTime; + var uptimeString = FormatUptime(uptime); + + // Thread count + var threadCount = currentProcess.Threads.Count; + + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + var data = new Dictionary + { + { "status", "healthy" }, + { "duration", $"{duration}ms" }, + { "memory", new Dictionary + { + { "total_managed_mb", Math.Round(totalMemory / 1024.0 / 1024.0, 2) }, + { "working_set_mb", Math.Round(workingSet / 1024.0 / 1024.0, 2) }, + { "gc_generation_0", GC.CollectionCount(0) }, + { "gc_generation_1", GC.CollectionCount(1) }, + { "gc_generation_2", GC.CollectionCount(2) } + } + }, + { "process", new Dictionary + { + { "id", currentProcess.Id }, + { "threads", threadCount }, + { "handles", currentProcess.HandleCount }, + { "uptime", uptimeString }, + { "uptime_seconds", (int)uptime.TotalSeconds } + } + }, + { "system", new Dictionary + { + { "processor_count", Environment.ProcessorCount }, + { "os_version", Environment.OSVersion.ToString() }, + { "machine_name", Environment.MachineName }, + { "user_name", Environment.UserName } + } + } + }; + + _logger.LogInformation("System resources health check completed in {Duration}ms - Memory: {Memory}MB, Threads: {Threads}", + duration, Math.Round(totalMemory / 1024.0 / 1024.0, 1), threadCount); + + // Definir thresholds para status + var memoryMb = totalMemory / 1024.0 / 1024.0; + + if (memoryMb > 1000) // > 1GB + { + data["status"] = "degraded"; + return Task.FromResult(HealthCheckResult.Degraded($"High memory usage: {memoryMb:F1}MB", data: data)); + } + + if (threadCount > 500) + { + data["status"] = "degraded"; + return Task.FromResult(HealthCheckResult.Degraded($"High thread count: {threadCount}", data: data)); + } + + return Task.FromResult(HealthCheckResult.Healthy($"System resources normal (Memory: {memoryMb:F1}MB, Threads: {threadCount})", data: data)); + } + catch (Exception ex) + { + stopwatch.Stop(); + var duration = stopwatch.ElapsedMilliseconds; + + _logger.LogError(ex, "System resources health check failed after {Duration}ms", duration); + + var data = new Dictionary + { + { "status", "unhealthy" }, + { "duration", $"{duration}ms" }, + { "error", ex.Message } + }; + + return Task.FromResult(HealthCheckResult.Unhealthy($"System resources check failed: {ex.Message}", ex, data)); + } + } + + private static double GetCpuUsage(Process process) + { + try + { + return process.TotalProcessorTime.TotalMilliseconds; + } + catch + { + return 0; + } + } + + private static string FormatUptime(TimeSpan uptime) + { + if (uptime.TotalDays >= 1) + return $"{(int)uptime.TotalDays}d {uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalHours >= 1) + return $"{uptime.Hours}h {uptime.Minutes}m"; + if (uptime.TotalMinutes >= 1) + return $"{uptime.Minutes}m {uptime.Seconds}s"; + return $"{uptime.Seconds}s"; + } +} \ No newline at end of file diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index 33410c9..66d5462 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -1,6 +1,7 @@ using BCards.Web.Configuration; using BCards.Web.Services; using BCards.Web.Repositories; +using BCards.Web.HealthChecks; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authentication.MicrosoftAccount; @@ -14,9 +15,74 @@ using SendGrid; using BCards.Web.Middleware; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.HttpOverrides; +using Serilog; +using Serilog.Events; +using Microsoft.Extensions.Diagnostics.HealthChecks; var builder = WebApplication.CreateBuilder(args); +// Configure Serilog with environment-specific settings +var isDevelopment = builder.Environment.IsDevelopment(); +var hostname = Environment.MachineName; + +var loggerConfig = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() + .Enrich.WithEnvironmentName() + .Enrich.WithProcessId() + .Enrich.WithThreadId() + .Enrich.WithProperty("ApplicationName", builder.Configuration["ApplicationName"] ?? "BCards") + .Enrich.WithProperty("Environment", builder.Environment.EnvironmentName) + .Enrich.WithProperty("Hostname", hostname); + +if (isDevelopment) +{ + // Development: Log EVERYTHING to console with detailed formatting + loggerConfig + .MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("System", LogEventLevel.Information) + .WriteTo.Async(a => a.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] [{Hostname}] {Message:lj} {Properties:j}{NewLine}{Exception}")); + + // Also send to Seq if configured (for local development with Seq) + var seqUrl = builder.Configuration["Serilog:SeqUrl"]; + if (!string.IsNullOrEmpty(seqUrl)) + { + var apiKey = builder.Configuration["Serilog:ApiKey"]; + loggerConfig.WriteTo.Async(a => a.Seq(seqUrl, apiKey: string.IsNullOrEmpty(apiKey) ? null : apiKey)); + } +} +else +{ + // Production: Only errors to console, everything to Seq + loggerConfig + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .MinimumLevel.Override("System", LogEventLevel.Warning) + .WriteTo.Async(a => a.Console( + restrictedToMinimumLevel: LogEventLevel.Error, + outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss} {Level:u3}] [{Hostname}] {Message:lj}{NewLine}{Exception}")); + + // Production: Send detailed logs to Seq + var seqUrl = builder.Configuration["Serilog:SeqUrl"]; + if (!string.IsNullOrEmpty(seqUrl)) + { + var apiKey = builder.Configuration["Serilog:ApiKey"]; + loggerConfig.WriteTo.Async(a => a.Seq(seqUrl, + apiKey: string.IsNullOrEmpty(apiKey) ? null : apiKey, + restrictedToMinimumLevel: LogEventLevel.Information)); + } +} + +Log.Logger = loggerConfig.CreateLogger(); + +// Use Serilog for the host +builder.Host.UseSerilog(); + +// Log startup information +Log.Information("Starting BCards application on {Hostname} in {Environment} mode", hostname, builder.Environment.EnvironmentName); + // 🔥 CONFIGURAR FORWARDED HEADERS NO BUILDER builder.Services.Configure(options => { @@ -208,6 +274,52 @@ builder.Services.AddMemoryCache(); builder.Services.AddRazorPages(); +// ===== CONFIGURAÇÃO DOS HEALTH CHECKS ===== +builder.Services.AddHealthChecks() + // MongoDB Health Check usando configuração existente + .AddCheck( + name: "mongodb", + failureStatus: HealthStatus.Unhealthy, + timeout: TimeSpan.FromSeconds(10)) + + // Stripe Health Check + .AddCheck( + name: "stripe", + failureStatus: HealthStatus.Degraded, // Stripe não é crítico para funcionalidade básica + timeout: TimeSpan.FromSeconds(15)) + + // SendGrid Health Check + .AddCheck( + name: "sendgrid", + failureStatus: HealthStatus.Degraded, // Email não é crítico para funcionalidade básica + timeout: TimeSpan.FromSeconds(10)) + + // External Services (OAuth providers) + .AddCheck( + name: "external_services", + failureStatus: HealthStatus.Degraded, // OAuth pode estar indisponível temporariamente + timeout: TimeSpan.FromSeconds(20)) + + // System Resources + .AddCheck( + name: "resources", + failureStatus: HealthStatus.Degraded, + timeout: TimeSpan.FromSeconds(5)); + +// Registrar health checks customizados no DI +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); + +// HttpClient para External Services Health Check +builder.Services.AddHttpClient(client => +{ + client.Timeout = TimeSpan.FromSeconds(10); + client.DefaultRequestHeaders.Add("User-Agent", "BCards-HealthCheck/1.0"); +}); + var app = builder.Build(); // 🔥 PRIMEIRA COISA APÓS BUILD - FORWARDED HEADERS + BASE URL OVERRIDE @@ -360,7 +472,21 @@ using (var scope = app.Services.CreateScope()) } } -app.Run(); +try +{ + Log.Information("BCards application started successfully on {Hostname}", hostname); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "BCards application terminated unexpectedly on {Hostname}", hostname); + throw; +} +finally +{ + Log.Information("BCards application shutting down on {Hostname}", hostname); + Log.CloseAndFlush(); +} // Make Program accessible for integration tests public partial class Program { } \ No newline at end of file diff --git a/src/BCards.Web/appsettings.Development.json b/src/BCards.Web/appsettings.Development.json index 7c69eda..41702e7 100644 --- a/src/BCards.Web/appsettings.Development.json +++ b/src/BCards.Web/appsettings.Development.json @@ -7,6 +7,10 @@ "Microsoft.AspNetCore": "Warning" } }, + "Serilog": { + "SeqUrl": "http://192.168.0.100:5341", + "ApiKey": "" + }, "DetailedErrors": true, "MongoDb": { "ConnectionString": "mongodb://localhost:27017", diff --git a/src/BCards.Web/appsettings.Release.json b/src/BCards.Web/appsettings.Release.json index 85e0dee..5f8e8bb 100644 --- a/src/BCards.Web/appsettings.Release.json +++ b/src/BCards.Web/appsettings.Release.json @@ -5,6 +5,10 @@ "Microsoft.AspNetCore": "Warning" } }, + "Serilog": { + "SeqUrl": "http://seq.internal:5341", + "ApiKey": "YOUR_PRODUCTION_API_KEY" + }, "MongoDb": { "ConnectionString": "mongodb://192.168.0.100:27017", "DatabaseName": "BCardsDB_Staging" diff --git a/src/BCards.Web/appsettings.json b/src/BCards.Web/appsettings.json index 519fc70..df161c4 100644 --- a/src/BCards.Web/appsettings.json +++ b/src/BCards.Web/appsettings.json @@ -6,6 +6,10 @@ "Microsoft.Hosting.Lifetime": "Information" } }, + "Serilog": { + "SeqUrl": "http://localhost:5341", + "ApiKey": "" + }, "AllowedHosts": "*", "Stripe": { "PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",