Compare commits

..

2 Commits

Author SHA1 Message Date
Ricardo Carneiro
90cc01d7cf feat: heath checks, seq e logs
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 1s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m39s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m17s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
2025-08-24 20:00:53 -03:00
Ricardo Carneiro
2d901708b8 fix: erro de categoria e falta de uma pagina de erro. 2025-08-24 12:16:51 -03:00
17 changed files with 991 additions and 22 deletions

View File

@ -12,7 +12,7 @@ namespace BCards.IntegrationTests.Helpers;
public static class AuthenticationHelper
{
public static async Task<HttpClient> CreateAuthenticatedClientAsync(
public static Task<HttpClient> CreateAuthenticatedClientAsync(
WebApplicationFactory<Program> 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)

View File

@ -161,9 +161,9 @@ public class PuppeteerTestHelper : IAsyncDisposable
await Page.ScreenshotAsync(fileName);
}
public async Task<string> GetCurrentUrlAsync()
public Task<string> GetCurrentUrlAsync()
{
return Page.Url;
return Task.FromResult(Page.Url);
}
public async Task<List<string>> GetAllElementTextsAsync(string selector)

View File

@ -20,6 +20,15 @@
<PackageReference Include="Microsoft.AspNetCore.ResponseCaching" Version="2.2.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.54" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
<PackageReference Include="Serilog.Sinks.Seq" Version="6.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Process" Version="3.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.MongoDb" Version="7.0.0" />
</ItemGroup>
<ItemGroup>

View File

@ -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<ManagePageViewModel>(x => x.TwitterUrl);
ModelState.Remove<ManagePageViewModel>(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)
{

View File

@ -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<HealthController> _logger;
private readonly IConfiguration _configuration;
private static readonly DateTime _startTime = DateTime.UtcNow;
public HealthController(HealthCheckService healthCheckService, ILogger<HealthController> logger, IConfiguration configuration)
{
_healthCheckService = healthCheckService;
_logger = logger;
_configuration = configuration;
}
/// <summary>
/// Health check simples - retorna apenas status geral
/// </summary>
[HttpGet]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Health check detalhado - formato completo com métricas
/// </summary>
[HttpGet("detailed")]
public async Task<IActionResult> GetDetailedHealth()
{
var stopwatch = Stopwatch.StartNew();
try
{
var healthReport = await _healthCheckService.CheckHealthAsync();
stopwatch.Stop();
var checks = new Dictionary<string, object>();
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"
});
}
}
/// <summary>
/// Health check para Uptime Kuma - formato específico
/// </summary>
[HttpGet("uptime-kuma")]
public async Task<IActionResult> 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
});
}
}
/// <summary>
/// Health checks específicos por serviço
/// </summary>
[HttpGet("mongodb")]
public async Task<IActionResult> GetMongoDbHealth()
{
return await GetSpecificServiceHealth("mongodb");
}
[HttpGet("stripe")]
public async Task<IActionResult> GetStripeHealth()
{
return await GetSpecificServiceHealth("stripe");
}
[HttpGet("sendgrid")]
public async Task<IActionResult> GetSendGridHealth()
{
return await GetSpecificServiceHealth("sendgrid");
}
[HttpGet("external")]
public async Task<IActionResult> GetExternalServicesHealth()
{
return await GetSpecificServiceHealth("external_services");
}
[HttpGet("resources")]
public async Task<IActionResult> GetSystemResourcesHealth()
{
return await GetSpecificServiceHealth("resources");
}
private async Task<IActionResult> 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";
}
}

View File

@ -36,22 +36,17 @@ public class HomeController : Controller
return View();
}
[Route("categoria/{categorySlug}")]
public async Task<IActionResult> Category(string categorySlug)
[Route("health")]
public IActionResult Health()
{
ViewBag.IsHomePage = true;
var category = await _categoryService.GetCategoryBySlugAsync(categorySlug);
if (category == null)
return NotFound();
var pages = await _userPageService.GetPagesByCategoryAsync(categorySlug, 20);
ViewBag.Category = category;
ViewBag.Pages = pages;
return View();
return Ok(new {
status = "healthy",
timestamp = DateTime.UtcNow,
version = "1.0.0"
});
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{

View File

@ -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<IActionResult> Display(string category, string slug)
{
var userPage = await _userPageService.GetPageAsync(category, slug);

View File

@ -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<ExternalServicesHealthCheck> _logger;
public ExternalServicesHealthCheck(HttpClient httpClient, ILogger<ExternalServicesHealthCheck> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var results = new Dictionary<string, object>();
var allHealthy = true;
var hasUnhealthy = false;
try
{
// Lista de serviços externos para verificar
var services = new Dictionary<string, string>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "status", hasUnhealthy ? "unhealthy" : (allHealthy ? "healthy" : "degraded") },
{ "duration", $"{totalDuration}ms" },
{ "services", results },
{ "total_services", services.Count },
{ "healthy_services", results.Values.Count(r => ((Dictionary<string, object>)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<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"External services check failed: {ex.Message}", ex, data);
}
}
}

View File

@ -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<MongoDbHealthCheck> _logger;
public MongoDbHealthCheck(IMongoDatabase database, ILogger<MongoDbHealthCheck> logger)
{
_database = database;
_logger = logger;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
try
{
// Executa ping no MongoDB
var command = new BsonDocument("ping", 1);
await _database.RunCommandAsync<BsonDocument>(command, cancellationToken: cancellationToken);
stopwatch.Stop();
var duration = stopwatch.ElapsedMilliseconds;
_logger.LogInformation("MongoDB health check completed successfully in {Duration}ms", duration);
var data = new Dictionary<string, object>
{
{ "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<string, object>
{
{ "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);
}
}
}

View File

@ -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<SendGridHealthCheck> _logger;
private readonly IConfiguration _configuration;
public SendGridHealthCheck(ISendGridClient sendGridClient, ILogger<SendGridHealthCheck> logger, IConfiguration configuration)
{
_sendGridClient = sendGridClient;
_logger = logger;
_configuration = configuration;
}
public async Task<HealthCheckResult> 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<string, object>
{
{ "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<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"SendGrid connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -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<StripeHealthCheck> _logger;
public StripeHealthCheck(IOptions<StripeSettings> stripeSettings, ILogger<StripeHealthCheck> logger)
{
_stripeSettings = stripeSettings.Value;
_logger = logger;
}
public async Task<HealthCheckResult> 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<string, object>
{
{ "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<string, object>
{
{ "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<string, object>
{
{ "status", "unhealthy" },
{ "duration", $"{duration}ms" },
{ "error", ex.Message }
};
return HealthCheckResult.Unhealthy($"Stripe connection failed: {ex.Message}", ex, data);
}
}
}

View File

@ -0,0 +1,134 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using System.Diagnostics;
namespace BCards.Web.HealthChecks;
public class SystemResourcesHealthCheck : IHealthCheck
{
private readonly ILogger<SystemResourcesHealthCheck> _logger;
private static readonly DateTime _startTime = DateTime.UtcNow;
public SystemResourcesHealthCheck(ILogger<SystemResourcesHealthCheck> logger)
{
_logger = logger;
}
public Task<HealthCheckResult> 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<string, object>
{
{ "status", "healthy" },
{ "duration", $"{duration}ms" },
{ "memory", new Dictionary<string, object>
{
{ "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<string, object>
{
{ "id", currentProcess.Id },
{ "threads", threadCount },
{ "handles", currentProcess.HandleCount },
{ "uptime", uptimeString },
{ "uptime_seconds", (int)uptime.TotalSeconds }
}
},
{ "system", new Dictionary<string, object>
{
{ "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<string, object>
{
{ "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";
}
}

View File

@ -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<ForwardedHeadersOptions>(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<MongoDbHealthCheck>(
name: "mongodb",
failureStatus: HealthStatus.Unhealthy,
timeout: TimeSpan.FromSeconds(10))
// Stripe Health Check
.AddCheck<StripeHealthCheck>(
name: "stripe",
failureStatus: HealthStatus.Degraded, // Stripe não é crítico para funcionalidade básica
timeout: TimeSpan.FromSeconds(15))
// SendGrid Health Check
.AddCheck<SendGridHealthCheck>(
name: "sendgrid",
failureStatus: HealthStatus.Degraded, // Email não é crítico para funcionalidade básica
timeout: TimeSpan.FromSeconds(10))
// External Services (OAuth providers)
.AddCheck<ExternalServicesHealthCheck>(
name: "external_services",
failureStatus: HealthStatus.Degraded, // OAuth pode estar indisponível temporariamente
timeout: TimeSpan.FromSeconds(20))
// System Resources
.AddCheck<SystemResourcesHealthCheck>(
name: "resources",
failureStatus: HealthStatus.Degraded,
timeout: TimeSpan.FromSeconds(5));
// Registrar health checks customizados no DI
builder.Services.AddTransient<MongoDbHealthCheck>();
builder.Services.AddTransient<StripeHealthCheck>();
builder.Services.AddTransient<SendGridHealthCheck>();
builder.Services.AddTransient<ExternalServicesHealthCheck>();
builder.Services.AddTransient<SystemResourcesHealthCheck>();
// HttpClient para External Services Health Check
builder.Services.AddHttpClient<ExternalServicesHealthCheck>(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 { }

View File

@ -0,0 +1,26 @@
@{
ViewData["Title"] = "Erro";
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Erro - BCards</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="alert alert-danger">
<h4>Oops! Algo deu errado.</h4>
<p>Ocorreu um erro inesperado. Por favor, tente novamente.</p>
<a href="/" class="btn btn-primary">Voltar ao Início</a>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -7,6 +7,10 @@
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"SeqUrl": "http://192.168.0.100:5341",
"ApiKey": ""
},
"DetailedErrors": true,
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",

View File

@ -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"

View File

@ -6,6 +6,10 @@
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Serilog": {
"SeqUrl": "http://localhost:5341",
"ApiKey": ""
},
"AllowedHosts": "*",
"Stripe": {
"PublishableKey": "pk_test_51RjUmIBMIadsOxJVP4bWc54pHEOSf5km1hpOkOBSoGVoKxI46N4KSWtevpXCSq68OjFazBuXmPJGBwZ1KDN5MNJy003lj1YmAS",