diff --git a/.gitignore b/.gitignore index e5c72350..6ee319d4 100644 --- a/.gitignore +++ b/.gitignore @@ -153,4 +153,7 @@ codeql-results.csv security-scan-results.csv # MemoryLens MCP fork -memorylens-mcp/ \ No newline at end of file +memorylens-mcp/ + +# Profiling snapshots +profiling/snapshots/ \ No newline at end of file diff --git a/Makefile b/Makefile index a78da9f8..21a20626 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # ServerEye Web - Environment Management Makefile # Enterprise-level environment management with component separation -.PHONY: help dev-up dev-down dev-logs dev-clean build test lint dev-infra-up dev-observability-up dev-backend-up dev-frontend-up dev-stripe-up dev-infra-down dev-observability-down dev-backend-down dev-frontend-down dev-stripe-down dev-infra-logs dev-observability-logs dev-backend-logs dev-frontend-logs dev-stripe-logs dev-shell +.PHONY: help dev-up dev-down dev-logs dev-clean build test lint dev-infra-up dev-observability-up dev-backend-up dev-frontend-up dev-stripe-up dev-infra-down dev-observability-down dev-backend-down dev-frontend-down dev-stripe-down dev-infra-logs dev-observability-logs dev-backend-logs dev-frontend-logs dev-stripe-logs dev-shell prod-up prod-down prod-logs prod-clean prod-infra-up prod-observability-up prod-backend-up prod-frontend-up prod-stripe-up prof-memory prof-cpu # Default target help: @@ -31,7 +31,13 @@ help: @echo " dev-backend-logs - Show backend logs" @echo " dev-frontend-logs - Show frontend logs" @echo "" - @echo "� Build & Test:" + @echo "🚀 Production - Full Stack:" + @echo " prod-up - Start all production services (infra + stripe + observability + backend + frontend)" + @echo " prod-down - Stop all production services" + @echo " prod-logs - Show all production logs" + @echo " prod-clean - Clean all production services" + @echo "" + @echo "🏗️ Build & Test:" @echo " build - Build all development services" @echo " test - Run all tests (including Docker tests)" @echo " test-backend - Run all backend tests" @@ -43,6 +49,10 @@ help: @echo " clean - Clean all environments" @echo " status - Show status of all services" @echo " backup - Backup databases" + @echo "" + @echo "🔍 Profiling:" + @echo " prof-memory - Create memory snapshot with manual load testing (60s)" + @echo " prof-cpu - Create CPU profiling snapshot with dotTrace (60s)" # ============================================================================== # DEVELOPMENT ENVIRONMENT @@ -165,6 +175,71 @@ dev-shell: @echo "🐚 Accessing development backend shell..." docker exec -it servereye-backend-dev /bin/sh +# ============================================================================== +# PRODUCTION ENVIRONMENT +# ============================================================================== + +# Full Stack Production +prod-up: prod-infra-up prod-stripe-up prod-observability-up prod-backend-up prod-frontend-up + @echo "All production services started!" + @echo "Frontend: http://127.0.0.1:3001" + @echo "Backend: http://127.0.0.1:5248" + @echo "PostgreSQL: 127.0.0.1:5435 (main), 127.0.0.1:5437 (tickets), 127.0.0.1:5438 (billing)" + @echo "Redis: 127.0.0.1:6381" + @echo "Grafana: http://localhost:3011" + @echo "Prometheus: http://localhost:9091" + +prod-down: prod-frontend-down prod-backend-down prod-stripe-down prod-observability-down prod-infra-down + @echo "✅ All production services stopped!" + +prod-logs: + @echo "Showing all production logs..." + docker compose -f ./environments/prod/infrastructure/docker-compose.yml logs -f & \ + docker compose -f ./environments/prod/stripe/docker-compose.yml logs -f & \ + docker compose -f ./environments/prod/observability/docker-compose.yml logs -f & \ + docker compose -f ./environments/prod/backend/docker-compose.yml logs -f & \ + docker compose -f ./environments/prod/frontend/docker-compose.yml logs -f + +prod-clean: prod-down + @echo "Cleaning production environment..." + docker compose -f ./environments/prod/infrastructure/docker-compose.yml down -v --remove-orphans + docker compose -f ./environments/prod/stripe/docker-compose.yml down -v --remove-orphans + docker compose -f ./environments/prod/observability/docker-compose.yml down -v --remove-orphans + docker compose -f ./environments/prod/backend/docker-compose.yml down -v --remove-orphans + docker compose -f ./environments/prod/frontend/docker-compose.yml down -v --remove-orphans + @echo "Production environment cleaned!" + +# Component-wise Production Commands +prod-infra-up: + @echo "🏗️ Starting production infrastructure..." + @docker network create servereye-network 2>/dev/null || echo "✅ Network servereye-network already exists" + cd ./environments/prod && doppler run -- docker compose -f ./infrastructure/docker-compose.yml up -d + @echo "✅ Infrastructure started!" + +prod-observability-up: + @echo "📊 Starting production observability stack..." + @docker network create servereye-network 2>/dev/null || echo "✅ Network servereye-network already exists" + cd ./environments/prod && doppler run -- docker compose -f ./observability/docker-compose.yml up -d + @echo "✅ Observability stack started!" + +prod-backend-up: + @echo "🔧 Starting production backend..." + @docker network create servereye-network 2>/dev/null || echo "✅ Network servereye-network already exists" + cd ./environments/prod && doppler run -- docker compose -f ./backend/docker-compose.yml up -d --build + @echo "✅ Backend started!" + +prod-frontend-up: + @echo "🌐 Starting production frontend..." + @docker network create servereye-network 2>/dev/null || echo "✅ Network servereye-network already exists" + cd ./environments/prod && doppler run -- docker compose -f ./frontend/docker-compose.yml up -d --build + @echo "✅ Frontend started!" + +prod-stripe-up: + @echo "Starting Stripe CLI for production webhook forwarding..." + @docker network create servereye-network 2>/dev/null || echo "✅ Network servereye-network already exists" + cd ./environments/prod && doppler run -- docker compose -f ./stripe/docker-compose.yml up -d + @echo "✅ Stripe CLI started!" + # ============================================================================== # BUILD COMMANDS # ============================================================================== @@ -279,6 +354,18 @@ restore: docker exec -i servereyeWeb-ticket-postgres psql -U postgres ServerEyeWeb_Dev_Ticket < "$$ticket_backup" @echo "✅ Databases restored!" +# ============================================================================== +# PROFILING COMMANDS +# ============================================================================== + +prof-memory: + @echo "🔍 Starting memory profiling..." + @./profiling/memory/memory-snapshot-auto-load.sh + +prof-cpu: + @echo "🔍 Starting CPU profiling with dotTrace..." + @./profiling/dottrace/prof-cpu.sh + # ============================================================================== # DEVELOPMENT HELPERS # ============================================================================== diff --git a/backend/ServerEyeBackend/Directory.Packages.props b/backend/ServerEyeBackend/Directory.Packages.props index be14e80e..e30f79fe 100644 --- a/backend/ServerEyeBackend/Directory.Packages.props +++ b/backend/ServerEyeBackend/Directory.Packages.props @@ -4,26 +4,26 @@ - - - + + + - + - + - - - - - - + + + + + + - + @@ -31,30 +31,30 @@ - - + + - - + + - - + + - - - - + + + + - + - - - - + + + + diff --git a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/AuthenticationSetup.cs b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/AuthenticationSetup.cs index d0355e28..b51f87be 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/AuthenticationSetup.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/AuthenticationSetup.cs @@ -35,14 +35,14 @@ public static IServiceCollection AddAuthenticationConfiguration( { // Load RSA key from JwtSettings RSA rsaKey; - if (!string.IsNullOrEmpty(jwtSettings.PrivateKeyBase64) && !string.IsNullOrEmpty(jwtSettings.PublicKeyBase64)) + if (!string.IsNullOrEmpty(jwtSettings.PrivateKey) && !string.IsNullOrEmpty(jwtSettings.PublicKey)) { - rsaKey = LoadRsaKeyFromBase64(jwtSettings.PublicKeyBase64); + rsaKey = LoadRsaKeyFromBase64(jwtSettings.PublicKey); } else { throw new InvalidOperationException( - "JWT PrivateKeyBase64 and PublicKeyBase64 must be configured. " + + "JWT PrivateKey and PublicKey must be configured. " + "Please add them to Doppler dev config."); } diff --git a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DatabaseSetup.cs b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DatabaseSetup.cs index 56cc3bab..85dbe0fa 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DatabaseSetup.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DatabaseSetup.cs @@ -28,15 +28,24 @@ public static IServiceCollection AddDatabaseConfiguration( return services; } - // Register DbContexts - services.AddDbContext(options => - options.UseNpgsql(serverEyeConnectionString)); + // Register DbContexts with pooling for better performance and reduced memory allocations + services.AddDbContextPool(options => + { + options.UseNpgsql(serverEyeConnectionString); + options.ConfigureWarnings(warnings => warnings.Default(WarningBehavior.Ignore)); + }); - services.AddDbContext(options => - options.UseNpgsql(ticketConnectionString)); + services.AddDbContextPool(options => + { + options.UseNpgsql(ticketConnectionString); + options.ConfigureWarnings(warnings => warnings.Default(WarningBehavior.Ignore)); + }); - services.AddDbContext(options => - options.UseNpgsql(billingConnectionString)); + services.AddDbContextPool(options => + { + options.UseNpgsql(billingConnectionString); + options.ConfigureWarnings(warnings => warnings.Default(WarningBehavior.Ignore)); + }); // Add Health Checks for all databases services.AddHealthChecks() diff --git a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DependencyInjectionSetup.cs b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DependencyInjectionSetup.cs index e2045c08..ede8b3b5 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DependencyInjectionSetup.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/DependencyInjectionSetup.cs @@ -151,11 +151,11 @@ private static void RegisterAuthServices(IServiceCollection services) // Log JWT settings for debugging logger?.LogInformation( - "Registering JwtService - PrivateKeyBase64 length: {Length}, PublicKeyBase64 length: {Length}", - jwtSettings.PrivateKeyBase64?.Length ?? 0, - jwtSettings.PublicKeyBase64?.Length ?? 0); + "Registering JwtService - PrivateKey length: {Length}, PublicKey length: {Length}", + jwtSettings.PrivateKey?.Length ?? 0, + jwtSettings.PublicKey?.Length ?? 0); - return new JwtService(jwtSettings, configuration, logger); + return new JwtService(jwtSettings, logger); }); } @@ -294,11 +294,15 @@ private static void AddHealthChecks(IServiceCollection services, IConfiguration .AddCheck>("main_database", tags: DatabaseTags) .AddCheck>("ticket_database", tags: DatabaseTags); - // Register factory instances with database names + // Register factory instances with database names using IServiceScopeFactory services.AddSingleton(provider => - new DatabaseHealthCheckFactory(provider, "Main Database")); + new DatabaseHealthCheckFactory( + provider.GetRequiredService(), + "Main Database")); services.AddSingleton(provider => - new DatabaseHealthCheckFactory(provider, "Ticket Database")); + new DatabaseHealthCheckFactory( + provider.GetRequiredService(), + "Ticket Database")); // Add Redis health check if configured var redisConnectionString = configuration.GetConnectionString("Redis"); diff --git a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/OpenTelemetryConfiguration.cs b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/OpenTelemetryConfiguration.cs index 18c98cf4..410c1936 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/OpenTelemetryConfiguration.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Configuration/Extensions/OpenTelemetryConfiguration.cs @@ -48,13 +48,11 @@ public static IServiceCollection AddOpenTelemetryConfiguration( { var path = httpContext.Request.Path; - // Exclude health and OAuth endpoints from tracing - return !path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) && - !path.StartsWithSegments("/api/auth/oauth", StringComparison.OrdinalIgnoreCase); + // Exclude health endpoints from tracing + return !path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase); }; options.EnrichWithHttpRequest = (activity, httpRequest) => { - activity.SetTag("http.request.path", httpRequest.Path); activity.SetTag("http.request.query", httpRequest.QueryString.ToString()); }; options.EnrichWithHttpResponse = (activity, httpResponse) => @@ -73,17 +71,16 @@ public static IServiceCollection AddOpenTelemetryConfiguration( }) .AddEntityFrameworkCoreInstrumentation(options => { - options.SetDbStatementForText = true; + options.SetDbStatementForText = false; options.SetDbStatementForStoredProcedure = true; options.EnrichWithIDbCommand = (activity, command) => { - activity.SetTag("db.command.text", command.CommandText); activity.SetTag("db.command.type", command.CommandType.ToString()); }; }) .AddSource(serviceName) .AddSource("ServerEye.OAuth") // Add OAuth activity source - .SetSampler(new AlwaysOnSampler()) + .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1))) .AddOtlpExporter(options => { options.Endpoint = new Uri(otlpEndpoint); @@ -94,8 +91,8 @@ public static IServiceCollection AddOpenTelemetryConfiguration( { tracing.AddRedisInstrumentation(connection => { - connection.SetVerboseDatabaseStatements = true; - connection.EnrichActivityWithTimingEvents = true; + connection.SetVerboseDatabaseStatements = false; + connection.EnrichActivityWithTimingEvents = false; }); } }) @@ -123,7 +120,6 @@ public static IServiceCollection AddOpenTelemetryConfiguration( services.AddLogging(logging => { logging.ClearProviders(); - logging.AddConsole(); logging.AddOpenTelemetry(options => { options.SetResourceBuilder(ResourceBuilder.CreateDefault() diff --git a/backend/ServerEyeBackend/ServerEye.API/Controllers/Auth/AuthController.cs b/backend/ServerEyeBackend/ServerEye.API/Controllers/Auth/AuthController.cs index cd458c7a..79b2f0e8 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Controllers/Auth/AuthController.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Controllers/Auth/AuthController.cs @@ -94,7 +94,7 @@ public async Task> RefreshToken([FromBody] Refresh if (!string.IsNullOrEmpty(request.Token)) { - var principal = this.jwtService.ValidateToken(request.Token, validateLifetime: false); + var principal = this.jwtService.ValidateExpiredAccessToken(request.Token); if (principal != null) { email = principal.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? string.Empty; diff --git a/backend/ServerEyeBackend/ServerEye.API/Extensions/DopplerConfigurationExtensions.cs b/backend/ServerEyeBackend/ServerEye.API/Extensions/DopplerConfigurationExtensions.cs index 83bd37b5..00c1f9ce 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Extensions/DopplerConfigurationExtensions.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Extensions/DopplerConfigurationExtensions.cs @@ -95,10 +95,10 @@ public override void Load() _logger?.LogInformation("Loaded {Count} secrets from environment variables", secrets.Count); // Log JWT secrets specifically for debugging - var jwtPrivateKey = secrets.FirstOrDefault(s => s.Key == "JwtSettings:PrivateKeyBase64"); - var jwtPublicKey = secrets.FirstOrDefault(s => s.Key == "JwtSettings:PublicKeyBase64"); + var jwtPrivateKey = secrets.FirstOrDefault(s => s.Key == "JwtSettings:PrivateKey"); + var jwtPublicKey = secrets.FirstOrDefault(s => s.Key == "JwtSettings:PublicKey"); _logger?.LogInformation( - "JWT secrets loaded - PrivateKeyBase64: {HasValue}, PublicKeyBase64: {HasValue}", + "JWT secrets loaded - PrivateKey: {HasValue}, PublicKey: {HasValue}", jwtPrivateKey.Value != null, jwtPublicKey.Value != null); } @@ -146,8 +146,10 @@ private static string ConvertEnvironmentVariableToConfigurationKey(string envVar ["OAUTH_TELEGRAM_BOT_TOKEN"] = "OAuth:Telegram:BotToken", ["OAUTH_TELEGRAM_ENABLED"] = "OAuth:Telegram:Enabled", ["OAUTH_TELEGRAM_REDIRECT_URI"] = "OAuth:Telegram:RedirectUri", - ["JWT_PRIVATE_KEY_BASE64"] = "JwtSettings:PrivateKeyBase64", - ["JWT_PUBLIC_KEY_BASE64"] = "JwtSettings:PublicKeyBase64", + ["JWT_PRIVATE_KEY"] = "JwtSettings:PrivateKey", + ["JWT_PUBLIC_KEY"] = "JwtSettings:PublicKey", + ["JWT_PRIVATE_KEY_BASE64"] = "JwtSettings:PrivateKey", + ["JWT_PUBLIC_KEY_BASE64"] = "JwtSettings:PublicKey", ["JWT_SECRET_KEY"] = "JwtSettings:SecretKey", ["ENCRYPTION_KEY"] = "Encryption:Key", ["STRIPE_SECRET_KEY"] = "Stripe:SecretKey", diff --git a/backend/ServerEyeBackend/ServerEye.API/HealthChecks/DatabaseHealthCheck.cs b/backend/ServerEyeBackend/ServerEye.API/HealthChecks/DatabaseHealthCheck.cs index 14ae6520..4d0a0e69 100644 --- a/backend/ServerEyeBackend/ServerEye.API/HealthChecks/DatabaseHealthCheck.cs +++ b/backend/ServerEyeBackend/ServerEye.API/HealthChecks/DatabaseHealthCheck.cs @@ -75,16 +75,21 @@ public async Task CheckHealthAsync( /// /// Factory for creating DatabaseHealthCheck instances with database name. +/// Optimized to reuse scoped DbContext and cache results. /// public class DatabaseHealthCheckFactory : IHealthCheck where TContext : DbContext { - private readonly IServiceProvider serviceProvider; + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(5); + + private readonly IServiceScopeFactory scopeFactory; private readonly string databaseName; + private HealthCheckResult? cachedResult; + private DateTime lastCheckTime = DateTime.MinValue; - public DatabaseHealthCheckFactory(IServiceProvider serviceProvider, string databaseName) + public DatabaseHealthCheckFactory(IServiceScopeFactory scopeFactory, string databaseName) { - this.serviceProvider = serviceProvider; + this.scopeFactory = scopeFactory; this.databaseName = databaseName; } @@ -92,9 +97,21 @@ public async Task CheckHealthAsync( HealthCheckContext context, CancellationToken cancellationToken = default) { - using var scope = serviceProvider.CreateScope(); + // Return cached result if still valid + if (cachedResult != null && DateTime.UtcNow - lastCheckTime < CacheDuration) + { + return cachedResult.Value; + } + + using var scope = scopeFactory.CreateScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); var healthCheck = new DatabaseHealthCheck(dbContext, databaseName); - return await healthCheck.CheckHealthAsync(context, cancellationToken); + var result = await healthCheck.CheckHealthAsync(context, cancellationToken); + + // Cache the result + cachedResult = result; + lastCheckTime = DateTime.UtcNow; + + return result; } } diff --git a/backend/ServerEyeBackend/ServerEye.API/Program.cs b/backend/ServerEyeBackend/ServerEye.API/Program.cs index 59ed4351..5fac1d56 100644 --- a/backend/ServerEyeBackend/ServerEye.API/Program.cs +++ b/backend/ServerEyeBackend/ServerEye.API/Program.cs @@ -31,9 +31,9 @@ if (jwtSettings != null) { logger.LogInformation( - "JwtSettings loaded - PrivateKeyBase64 length: {Length}, PublicKeyBase64 length: {Length}", - jwtSettings.PrivateKeyBase64?.Length ?? 0, - jwtSettings.PublicKeyBase64?.Length ?? 0); + "JwtSettings loaded - PrivateKey length: {Length}, PublicKey length: {Length}", + jwtSettings.PrivateKey?.Length ?? 0, + jwtSettings.PublicKey?.Length ?? 0); } else { diff --git a/backend/ServerEyeBackend/ServerEye.Core/Interfaces/Services/Auth/IJwtService.cs b/backend/ServerEyeBackend/ServerEye.Core/Interfaces/Services/Auth/IJwtService.cs index 26dfc51e..fad3dbcc 100644 --- a/backend/ServerEyeBackend/ServerEye.Core/Interfaces/Services/Auth/IJwtService.cs +++ b/backend/ServerEyeBackend/ServerEye.Core/Interfaces/Services/Auth/IJwtService.cs @@ -8,7 +8,9 @@ public interface IJwtService public string GenerateAccessToken(User user); public string GenerateRefreshToken(User user); public ClaimsPrincipal? ValidateToken(string token); - public ClaimsPrincipal? ValidateToken(string token, bool validateLifetime); + public ClaimsPrincipal? ValidateAccessToken(string token); + public ClaimsPrincipal? ValidateRefreshToken(string token); + public ClaimsPrincipal? ValidateExpiredAccessToken(string token); public bool IsTokenExpired(string token); public string GetUserIdFromToken(string token); } diff --git a/backend/ServerEyeBackend/ServerEye.Core/Services/Auth/JwtService.cs b/backend/ServerEyeBackend/ServerEye.Core/Services/Auth/JwtService.cs index a8beab14..c74bdc96 100644 --- a/backend/ServerEyeBackend/ServerEye.Core/Services/Auth/JwtService.cs +++ b/backend/ServerEyeBackend/ServerEye.Core/Services/Auth/JwtService.cs @@ -16,90 +16,101 @@ namespace ServerEye.Core.Services; public class JwtSettings { - public string SecretKey { get; set; } = string.Empty; public string Issuer { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty; public TimeSpan AccessTokenExpiration { get; set; } public TimeSpan RefreshTokenExpiration { get; set; } - public string PrivateKeyBase64 { get; set; } = string.Empty; - public string PublicKeyBase64 { get; set; } = string.Empty; + public string PrivateKey { get; set; } = string.Empty; + public string PublicKey { get; set; } = string.Empty; + public string KeyId { get; set; } = string.Empty; } -public sealed class JwtService : IJwtService +public sealed class JwtService : IJwtService, IDisposable { - private static ILogger? staticLogger; private readonly JwtSettings jwtSettings; private readonly RSA rsaPublicKey; private readonly RSA rsaPrivateKey; private readonly ILogger? logger; + private bool disposed; - public JwtService(JwtSettings jwtSettings, IConfiguration configuration, ILogger? logger = null) + public JwtService(JwtSettings jwtSettings, ILogger? logger = null) { ArgumentNullException.ThrowIfNull(jwtSettings); - ArgumentNullException.ThrowIfNull(configuration); this.jwtSettings = jwtSettings; this.logger = logger; - staticLogger = logger; // Load RSA keys from JwtSettings - if (!string.IsNullOrEmpty(jwtSettings.PrivateKeyBase64) && !string.IsNullOrEmpty(jwtSettings.PublicKeyBase64)) + if (!string.IsNullOrEmpty(jwtSettings.PrivateKey) && !string.IsNullOrEmpty(jwtSettings.PublicKey)) { - this.rsaPrivateKey = LoadRsaKeyFromBase64(jwtSettings.PrivateKeyBase64); - this.rsaPublicKey = LoadRsaKeyFromBase64(jwtSettings.PublicKeyBase64); + this.rsaPrivateKey = LoadPrivateKey(jwtSettings.PrivateKey); + this.rsaPublicKey = LoadPublicKey(jwtSettings.PublicKey); logger?.LogInformation("Loaded RSA keys from JwtSettings"); } else { throw new InvalidOperationException( - "JWT PrivateKeyBase64 and PublicKeyBase64 must be configured. " + + "JWT PrivateKey and PublicKey must be configured. " + "Please add them to appsettings.Development.json or set via environment variables."); } } - private static RSA LoadRsaKeyFromBase64(string base64Key) + private static string RemoveWhitespace(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var c in value) + { + if (c != '\n' && c != '\r' && c != ' ' && c != '\t') + { + sb.Append(c); + } + } + return sb.ToString(); + } + + private RSA LoadPrivateKey(string value) { try { - staticLogger?.LogInformation("Original key length: {Length}", base64Key.Length); + var rsa = RSA.Create(); - // Remove whitespace and newlines - var cleanedKey = base64Key.Replace("\n", string.Empty, StringComparison.Ordinal) - .Replace("\r", string.Empty, StringComparison.Ordinal) - .Replace(" ", string.Empty, StringComparison.Ordinal) - .Replace("\t", string.Empty, StringComparison.Ordinal); + if (value.Contains("-----BEGIN", StringComparison.Ordinal)) + { + rsa.ImportFromPem(value); + return rsa; + } - staticLogger?.LogInformation("Cleaned key length: {Length}", cleanedKey.Length); + var bytes = Convert.FromBase64String(RemoveWhitespace(value)); + rsa.ImportPkcs8PrivateKey(bytes, out _); + return rsa; + } + catch (Exception ex) + { + this.logger?.LogError(ex, "Failed to load private RSA key"); + throw new InvalidOperationException("Failed to load private RSA key", ex); + } + } - // Log first 100 characters for debugging - var keyPreview = cleanedKey.Length > 100 ? cleanedKey[..100] : cleanedKey; - staticLogger?.LogInformation("Key preview (first 100 chars): {Preview}", keyPreview); + private RSA LoadPublicKey(string value) + { + try + { + var rsa = RSA.Create(); - // Check if key has PEM headers - if (base64Key.Contains("-----BEGIN", StringComparison.Ordinal)) + if (value.Contains("-----BEGIN", StringComparison.Ordinal)) { - staticLogger?.LogInformation("Key has PEM headers, importing directly"); - var rsa = RSA.Create(); - rsa.ImportFromPem(base64Key); - staticLogger?.LogInformation("Successfully loaded RSA key from PEM"); + rsa.ImportFromPem(value); return rsa; } - // Key is in pure Base64 format, add PEM headers - staticLogger?.LogInformation("Key is pure Base64, adding PEM headers"); - var pemKey = cleanedKey.Contains("MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJd", StringComparison.Ordinal) - ? $"-----BEGIN PRIVATE KEY-----\n{cleanedKey}\n-----END PRIVATE KEY-----" - : $"-----BEGIN PUBLIC KEY-----\n{cleanedKey}\n-----END PUBLIC KEY-----"; - - var rsaPem = RSA.Create(); - rsaPem.ImportFromPem(pemKey); - staticLogger?.LogInformation("Successfully loaded RSA key from PEM with added headers"); - return rsaPem; + var bytes = Convert.FromBase64String(RemoveWhitespace(value)); + rsa.ImportSubjectPublicKeyInfo(bytes, out _); + return rsa; } catch (Exception ex) { - staticLogger?.LogError(ex, "Failed to load RSA key"); - throw new InvalidOperationException("Failed to load RSA key", ex); + this.logger?.LogError(ex, "Failed to load public RSA key"); + throw new InvalidOperationException("Failed to load public RSA key", ex); } } @@ -110,25 +121,27 @@ public string GenerateAccessToken(User user) var tokenHandler = new JwtSecurityTokenHandler(); var key = new RsaSecurityKey(this.rsaPrivateKey) { - KeyId = Guid.NewGuid().ToString() + KeyId = this.jwtSettings.KeyId }; var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); var now = DateTime.UtcNow; var expires = now.Add(this.jwtSettings.AccessTokenExpiration); + var nowOffset = new DateTimeOffset(now); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), new Claim(JwtRegisteredClaimNames.Name, user.UserName), - new Claim("role", user.Role.ToString().ToUpperInvariant()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new Claim("type", "access"), + new Claim(System.Security.Claims.ClaimTypes.Role, user.Role.ToString().ToUpperInvariant()), new Claim("email_verified", user.IsEmailVerified.ToString().ToUpperInvariant()), new Claim("has_email", (!string.IsNullOrEmpty(user.Email)).ToString().ToUpperInvariant()), new Claim("has_password", user.HasPassword.ToString().ToUpperInvariant()), - new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64), - new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.Add(this.jwtSettings.AccessTokenExpiration).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64) + new Claim(JwtRegisteredClaimNames.Iat, nowOffset.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64) }; var tokenDescriptor = new SecurityTokenDescriptor @@ -160,24 +173,28 @@ public string GenerateRefreshToken(User user) var tokenHandler = new JwtSecurityTokenHandler(); var key = new RsaSecurityKey(this.rsaPrivateKey) { - KeyId = Guid.NewGuid().ToString() + KeyId = this.jwtSettings.KeyId }; var credentials = new SigningCredentials(key, SecurityAlgorithms.RsaSha256); + var now = DateTime.UtcNow; + var expires = now.Add(this.jwtSettings.RefreshTokenExpiration); + var nowOffset = new DateTimeOffset(now); + var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), new Claim(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), new Claim("type", "refresh"), - new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64), - new Claim(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.Add(this.jwtSettings.RefreshTokenExpiration).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64) + new Claim(JwtRegisteredClaimNames.Iat, nowOffset.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), ClaimValueTypes.Integer64) }; var tokenDescriptor = new SecurityTokenDescriptor { Subject = new ClaimsIdentity(claims), - Expires = DateTime.UtcNow.Add(this.jwtSettings.RefreshTokenExpiration), + Expires = expires, SigningCredentials = credentials, Issuer = this.jwtSettings.Issuer, Audience = this.jwtSettings.Audience @@ -189,10 +206,56 @@ public string GenerateRefreshToken(User user) public ClaimsPrincipal? ValidateToken(string token) { - return ValidateToken(token, validateLifetime: true); + return ValidateTokenInternal(token, validateLifetime: true); + } + + public ClaimsPrincipal? ValidateAccessToken(string token) + { + var principal = ValidateTokenInternal(token, validateLifetime: true); + + if (principal is null) + { + return null; + } + + if (principal.FindFirst("type")?.Value != "access") + { + return null; + } + + return principal; + } + + public ClaimsPrincipal? ValidateRefreshToken(string token) + { + var principal = ValidateTokenInternal(token, validateLifetime: true); + + if (principal is null) + { + return null; + } + + if (principal.FindFirst("type")?.Value != "refresh") + { + return null; + } + + return principal; + } + + public ClaimsPrincipal? ValidateExpiredAccessToken(string token) + { + var principal = ValidateTokenInternal(token, validateLifetime: false); + + if (principal?.FindFirst("type")?.Value != "access") + { + return null; + } + + return principal; } - public ClaimsPrincipal? ValidateToken(string token, bool validateLifetime) + private ClaimsPrincipal? ValidateTokenInternal(string token, bool validateLifetime) { if (string.IsNullOrEmpty(token)) { @@ -211,12 +274,20 @@ public string GenerateRefreshToken(User user) ValidIssuer = this.jwtSettings.Issuer, ValidAudience = this.jwtSettings.Audience, IssuerSigningKey = key, - ClockSkew = TimeSpan.Zero + ClockSkew = TimeSpan.Zero, + RoleClaimType = System.Security.Claims.ClaimTypes.Role }; try { var principal = tokenHandler.ValidateToken(token, validationParameters, out var validatedToken); + + if (validatedToken is not JwtSecurityToken jwtToken || + jwtToken.Header.Alg != SecurityAlgorithms.RsaSha256) + { + return null; + } + return principal; } catch (SecurityTokenValidationException) @@ -259,4 +330,17 @@ public string GetUserIdFromToken(string token) var principal = ValidateToken(token); return principal?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value ?? string.Empty; } + + public void Dispose() + { + if (this.disposed) + { + return; + } + + this.rsaPrivateKey?.Dispose(); + this.rsaPublicKey?.Dispose(); + this.disposed = true; + GC.SuppressFinalize(this); + } } diff --git a/backend/ServerEyeBackend/ServerEye.Infrastructure/ServerEye.Infrastructure.csproj b/backend/ServerEyeBackend/ServerEye.Infrastructure/ServerEye.Infrastructure.csproj index 7b0958b0..eab9fee8 100644 --- a/backend/ServerEyeBackend/ServerEye.Infrastructure/ServerEye.Infrastructure.csproj +++ b/backend/ServerEyeBackend/ServerEye.Infrastructure/ServerEye.Infrastructure.csproj @@ -12,12 +12,14 @@ + + diff --git a/backend/ServerEyeBackend/ServerEye.IntegrationTests/TestApplicationFactory.cs b/backend/ServerEyeBackend/ServerEye.IntegrationTests/TestApplicationFactory.cs index e71e9383..16716367 100644 --- a/backend/ServerEyeBackend/ServerEye.IntegrationTests/TestApplicationFactory.cs +++ b/backend/ServerEyeBackend/ServerEye.IntegrationTests/TestApplicationFactory.cs @@ -69,8 +69,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) ["JwtSettings:Audience"] = "TestAudience", ["JwtSettings:AccessTokenExpiration"] = "01:00:00", ["JwtSettings:RefreshTokenExpiration"] = "7.00:00:00", - ["JwtSettings:PrivateKeyBase64"] = TestPrivateKey, - ["JwtSettings:PublicKeyBase64"] = TestPublicKey, + ["JwtSettings:PrivateKey"] = TestPrivateKey, + ["JwtSettings:PublicKey"] = TestPublicKey, ["JWT_PRIVATE_KEY_BASE64"] = TestPrivateKey, ["JWT_PUBLIC_KEY_BASE64"] = TestPublicKey, // Use in-memory database @@ -332,13 +332,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // Override JwtService with test settings to ensure token generation uses same keys as validation var testJwtSettings = new Core.Services.JwtSettings { - SecretKey = "ThisIsASecretKeyForDevelopment123456789", Issuer = "TestIssuer", Audience = "TestAudience", AccessTokenExpiration = TimeSpan.Parse("01:00:00", CultureInfo.InvariantCulture), RefreshTokenExpiration = TimeSpan.Parse("7.00:00:00", CultureInfo.InvariantCulture), - PrivateKeyBase64 = TestPrivateKey, - PublicKeyBase64 = TestPublicKey + PrivateKey = TestPrivateKey, + PublicKey = TestPublicKey }; // Remove existing JwtSettings and JwtService @@ -359,7 +358,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.AddSingleton(provider => { var settings = provider.GetRequiredService(); - return new Core.Services.JwtService(settings, provider.GetRequiredService()); + return new Core.Services.JwtService(settings, provider.GetRequiredService>()); }); }); diff --git a/backend/ServerEyeBackend/ServerEye.UnitTests/Services/JwtServiceTests.cs b/backend/ServerEyeBackend/ServerEye.UnitTests/Services/JwtServiceTests.cs index dc3c5a05..2ae54270 100644 --- a/backend/ServerEyeBackend/ServerEye.UnitTests/Services/JwtServiceTests.cs +++ b/backend/ServerEyeBackend/ServerEye.UnitTests/Services/JwtServiceTests.cs @@ -7,7 +7,7 @@ namespace ServerEye.UnitTests.Services; using Microsoft.Extensions.Logging; using ServerEye.Core.Services; -public class JwtServiceTests +public class JwtServiceTests : IDisposable { private readonly Mock> loggerMock; private readonly JwtSettings jwtSettings; @@ -16,11 +16,9 @@ public class JwtServiceTests public JwtServiceTests() { this.loggerMock = new Mock>(); - var configurationMock1 = new Mock(); this.jwtSettings = new JwtSettings { - SecretKey = "TestSecretKey123456789012345678901234567890", Issuer = "TestIssuer", Audience = "TestAudience", AccessTokenExpiration = TimeSpan.FromMinutes(60), @@ -28,10 +26,16 @@ public JwtServiceTests() }; using var keyPair = System.Security.Cryptography.RSA.Create(2048); - this.jwtSettings.PrivateKeyBase64 = $"-----BEGIN PRIVATE KEY-----\n{Convert.ToBase64String(keyPair.ExportPkcs8PrivateKey())}\n-----END PRIVATE KEY-----"; - this.jwtSettings.PublicKeyBase64 = $"-----BEGIN PUBLIC KEY-----\n{Convert.ToBase64String(keyPair.ExportSubjectPublicKeyInfo())}\n-----END PUBLIC KEY-----"; + this.jwtSettings.PrivateKey = $"-----BEGIN PRIVATE KEY-----\n{Convert.ToBase64String(keyPair.ExportPkcs8PrivateKey())}\n-----END PRIVATE KEY-----"; + this.jwtSettings.PublicKey = $"-----BEGIN PUBLIC KEY-----\n{Convert.ToBase64String(keyPair.ExportSubjectPublicKeyInfo())}\n-----END PUBLIC KEY-----"; - this.sut = new JwtService(this.jwtSettings, configurationMock1.Object); + this.sut = new JwtService(this.jwtSettings, this.loggerMock.Object); + } + + public void Dispose() + { + this.sut?.Dispose(); + GC.SuppressFinalize(this); } [Fact] diff --git a/environments/dev/backend/docker-compose.yml b/environments/dev/backend/docker-compose.yml index e6b0ac9d..b0ebf2b5 100644 --- a/environments/dev/backend/docker-compose.yml +++ b/environments/dev/backend/docker-compose.yml @@ -11,17 +11,19 @@ services: ports: - "5246:8080" - "5247:443" + - "5248:8081" environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:8080 + - ASPNETCORE_DIAGNOSTIC_PORTS=8081 - EMAIL_SETTINGS_FRONTEND_URL=${EMAIL_SETTINGS_FRONTEND_URL} - GOAPI_SETTINGS_BASE_URL=${GOAPI_SETTINGS_BASE_URL} - DATABASE_CONNECTION_STRING=${DATABASE_CONNECTION_STRING} - TICKET_DB_CONNECTION_STRING=${TICKET_DB_CONNECTION_STRING} - BILLING_DB_CONNECTION_STRING=${BILLING_DB_CONNECTION_STRING} - REDIS_CONNECTION_STRING=${REDIS_CONNECTION_STRING} - - JWT_PRIVATE_KEY_BASE64=${JWT_PRIVATE_KEY_BASE64} - - JWT_PUBLIC_KEY_BASE64=${JWT_PUBLIC_KEY_BASE64} + - JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY} + - JWT_PUBLIC_KEY=${JWT_PUBLIC_KEY} - JWT_DEV_PRIVATE_KEY=${JWT_DEV_PRIVATE_KEY} - ENCRYPTION_KEY=${ENCRYPTION_KEY} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} diff --git a/environments/dev/observability/docker-compose.yml b/environments/dev/observability/docker-compose.yml index 957ae1f2..8ee88ea5 100644 --- a/environments/dev/observability/docker-compose.yml +++ b/environments/dev/observability/docker-compose.yml @@ -51,13 +51,6 @@ services: networks: - servereye-network - docker-proxy-network - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - read_only: true - tmpfs: - - /tmp # Loki - Log Aggregation loki: @@ -128,13 +121,6 @@ services: restart: unless-stopped networks: - servereye-network - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - read_only: true - tmpfs: - - /tmp healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3200/ready"] interval: 10s diff --git a/environments/dev/stripe/docker-compose.yml b/environments/dev/stripe/docker-compose.yml index c81cfdba..41664748 100644 --- a/environments/dev/stripe/docker-compose.yml +++ b/environments/dev/stripe/docker-compose.yml @@ -6,7 +6,9 @@ services: image: stripe/stripe-cli:latest container_name: ServerEyeWeb-stripe-cli-dev command: listen --forward-to http://ServerEyeWeb-backend-dev:8080/api/billing/webhook/stripe --events checkout.session.completed,invoice.payment_succeeded,customer.subscription.created - restart: unless-stopped + environment: + - STRIPE_API_KEY=${STRIPE_API_KEY} + restart: no networks: - servereye-network diff --git a/profiling/dottrace/dottrace.tar.gz b/profiling/dottrace/dottrace.tar.gz new file mode 100644 index 00000000..25e65541 --- /dev/null +++ b/profiling/dottrace/dottrace.tar.gz @@ -0,0 +1,144 @@ + + + + + 404: Page not Found + + + + + + + + +
+
+
+
404
+
+

File not found

+
+

Looks like something went wrong:

+
    +
  • We have deprecated HTTP and don't provide automatic redirects to HTTPS because it is unsafe.
  • +
  • The file you're looking for doesn't exist or has been deleted.
  • +
+

Please check the URL and try again.

+
+ +
+
+
+
+ + \ No newline at end of file diff --git a/profiling/dottrace/install-dottrace.sh b/profiling/dottrace/install-dottrace.sh new file mode 100755 index 00000000..a410f0ef --- /dev/null +++ b/profiling/dottrace/install-dottrace.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Install dotTrace CLI for CPU profiling + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOTTRACE_DIR="$SCRIPT_DIR" +DOTTRACE_VERSION="2024.2" +DOTTRACE_URL="https://download.jetbrains.com/resharper/dotTrace/dotTraceCLILinux.2024.2.0.tar.gz" +DOTTRACE_ARCHIVE="$DOTTRACE_DIR/dottrace.tar.gz" +DOTTRACE_BIN="$DOTTRACE_DIR/dottrace" + +echo "📦 Installing dotTrace CLI v$DOTTRACE_VERSION..." + +# Check if already installed +if [ -f "$DOTTRACE_BIN" ]; then + echo "✅ dotTrace CLI already installed at $DOTTRACE_BIN" + "$DOTTRACE_BIN" version + exit 0 +fi + +# Download dotTrace CLI +echo "⬇️ Downloading dotTrace CLI from JetBrains..." +curl -L -o "$DOTTRACE_ARCHIVE" "$DOTTRACE_URL" + +# Check if download was successful +if [ ! -f "$DOTTRACE_ARCHIVE" ] || [ ! -s "$DOTTRACE_ARCHIVE" ]; then + echo "❌ Failed to download dotTrace CLI" + exit 1 +fi + +# Extract archive +echo "📂 Extracting dotTrace CLI..." +tar -xzf "$DOTTRACE_ARCHIVE" -C "$DOTTRACE_DIR" + +# Make executable +chmod +x "$DOTTRACE_BIN" + +# Cleanup +rm -f "$DOTTRACE_ARCHIVE" + +echo "✅ dotTrace CLI installed successfully at $DOTTRACE_BIN" +"$DOTTRACE_BIN" version diff --git a/profiling/dottrace/prof-cpu.sh b/profiling/dottrace/prof-cpu.sh new file mode 100755 index 00000000..e5b0e0ce --- /dev/null +++ b/profiling/dottrace/prof-cpu.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# CPU Snapshot with Manual Load Testing (dotTrace CLI) + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +CONTAINER_NAME="ServerEyeWeb-backend-dev" +SNAPSHOT_DIR="${PROJECT_ROOT}/profiling/snapshots/cpu" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +SNAPSHOT_NAME="cpu-snapshot-load-${TIMESTAMP}.dtp" +DURATION=${1:-60} # Default 60 seconds + +echo "" +echo " CPU Profiling with Manual Load Testing" +echo "================================================" +echo "Container: ${CONTAINER_NAME}" +echo "Duration: ${DURATION} seconds" +echo "" + +# Check if container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo " Error: Container ${CONTAINER_NAME} is not running" + exit 1 +fi + +# Check if dotTrace CLI is installed in container +echo " Checking dotTrace CLI installation..." +if ! docker exec ${CONTAINER_NAME} test -d ./dotTraceclt/tools; then + echo " Installing dotTrace CLI..." + docker exec ${CONTAINER_NAME} sh -c " + apt-get update -y && apt-get install -y wget unzip && \ + wget -O dotTraceclt.zip https://www.nuget.org/api/v2/package/JetBrains.dotTrace.CommandLineTools.linux-x64 && \ + unzip -q dotTraceclt.zip -d ./dotTraceclt && \ + chmod +x -R dotTraceclt/* + " + echo " dotTrace CLI installed" +else + echo " dotTrace CLI already installed" +fi + +# Get dotnet process PID from container +echo " Finding dotnet process in container..." +DOTNET_PID=$(docker exec ${CONTAINER_NAME} pgrep -f 'dotnet.*ServerEye.API.dll' | head -1) + +if [ -z "$DOTNET_PID" ]; then + echo " dotnet process not found in container" + exit 1 +fi + +echo " Found dotnet process PID: $DOTNET_PID" + +# Start profiling +echo "" +echo " Starting CPU profiler (${DURATION}s)..." +echo "" +echo " START YOUR LOAD TEST IN POSTMAN NOW!" +echo " Collection: profiling/load-test-collection.json" +echo " Iterations: 10000-50000" +echo " Delay: 1ms" +echo "" +echo " Profiling for ${DURATION} seconds..." + +# Cleanup old snapshot files +docker exec ${CONTAINER_NAME} rm -f /tmp/snapshot.dtp* 2>/dev/null || true + +# Use unique snapshot name based on timestamp +UNIQUE_SNAPSHOT="/tmp/snapshot-${TIMESTAMP}.dtp" + +# Run dotTrace CLI +mkdir -p "${SNAPSHOT_DIR}" +docker exec ${CONTAINER_NAME} sh -c "./dotTraceclt/tools/dottrace attach ${DOTNET_PID} --save-to=${UNIQUE_SNAPSHOT} --timeout=${DURATION}s --profiling-type=Sampling" + +# Wait a bit for snapshot to be saved +echo "" +echo " Waiting for snapshot to be saved..." +sleep 3 + +# Copy snapshot to host (all .dtp files) +echo " Copying snapshot to ${SNAPSHOT_DIR}/..." +docker cp "${CONTAINER_NAME}:${UNIQUE_SNAPSHOT}" "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" +docker exec ${CONTAINER_NAME} sh -c "ls ${UNIQUE_SNAPSHOT}.* 2>/dev/null" | while read file; do + filename=$(basename "$file") + docker cp "${CONTAINER_NAME}:${file}" "${SNAPSHOT_DIR}/${filename}" +done + +# Rename files to match main snapshot name +cd "${SNAPSHOT_DIR}" +BASENAME=$(basename "${UNIQUE_SNAPSHOT}") +for file in ${BASENAME}.*; do + if [ -f "$file" ]; then + suffix="${file#${BASENAME}.}" + mv "$file" "${SNAPSHOT_NAME}.${suffix}" + fi +done + +# Cleanup +docker exec ${CONTAINER_NAME} rm -f ${UNIQUE_SNAPSHOT}* 2>/dev/null || true + +# Get file size +FILE_SIZE=$(du -h "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" | cut -f1) + +echo "" +echo " Automated CPU profiling completed!" +echo "================================================" +echo " Snapshot: ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" +echo " Size: ${FILE_SIZE}" +echo "" +echo " Open in dotTrace Desktop:" +echo " File → Open → ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" diff --git a/profiling/load-test-collection.json b/profiling/load-test-collection.json new file mode 100644 index 00000000..dff28501 --- /dev/null +++ b/profiling/load-test-collection.json @@ -0,0 +1,130 @@ +{ + "info": { + "name": "ServerEye HEAVY Load Test Collection", + "description": "Serious load testing for memory profiling - Multiple endpoints with data processing", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Health Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5246/health", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["health"] + } + } + }, + { + "name": "Readiness Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5246/health/ready", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["health", "ready"] + } + } + }, + { + "name": "Liveness Check", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5246/health/live", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["health", "live"] + } + } + }, + { + "name": "Prometheus Metrics (Large Response)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5246/metrics", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["metrics"] + } + } + }, + { + "name": "Forgot Password - Heavy Processing", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"test{{$randomInt}}@example.com\"\n}" + }, + "url": { + "raw": "http://localhost:5246/api/auth/forgot-password", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["api", "auth", "forgot-password"] + } + } + }, + { + "name": "OAuth Challenge - Config Processing", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5246/api/auth/oauth/google/challenge", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["api", "auth", "oauth", "google", "challenge"] + } + } + }, + { + "name": "Get Session - Cache Read", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:5246/api/auth/session", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["api", "auth", "session"] + } + } + }, + { + "name": "Set Session - Cache Write", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:5246/api/auth/session", + "protocol": "http", + "host": ["localhost"], + "port": "5246", + "path": ["api", "auth", "session"] + } + } + } + ] +} diff --git a/profiling/memory/memory-snapshot-auto-load.sh b/profiling/memory/memory-snapshot-auto-load.sh new file mode 100755 index 00000000..1ef438a9 --- /dev/null +++ b/profiling/memory/memory-snapshot-auto-load.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +# Memory Snapshot with Manual Load Testing +# Starts profiling and waits for you to run load test manually + +set -e + +# Get script directory and project root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +CONTAINER_NAME="ServerEyeWeb-backend-dev" +SNAPSHOT_DIR="${PROJECT_ROOT}/profiling/snapshots/memory" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +SNAPSHOT_NAME="memory-snapshot-load-${TIMESTAMP}.dmw" +DURATION=${1:-60} # Default 60 seconds + +echo "🚀 Memory Profiling with Manual Load Testing" +echo "================================================" +echo "Container: ${CONTAINER_NAME}" +echo "Duration: ${DURATION} seconds" +echo "" + +# Check if container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "❌ Error: Container ${CONTAINER_NAME} is not running" + exit 1 +fi + +# Check if dotMemory CLI is installed in container +echo "📦 Checking dotMemory CLI installation..." +if ! docker exec ${CONTAINER_NAME} test -d ./dotMemoryclt/tools; then + echo "📥 Installing dotMemory CLI..." + docker exec ${CONTAINER_NAME} sh -c " + apt-get update -y && apt-get install -y wget unzip && \ + wget -O dotMemoryclt.zip https://www.nuget.org/api/v2/package/JetBrains.dotMemory.Console.linux-x64 && \ + unzip -q dotMemoryclt.zip -d ./dotMemoryclt && \ + chmod +x -R dotMemoryclt/* + " + echo "✅ dotMemory CLI installed" +else + echo "✅ dotMemory CLI already installed" +fi + +# Start profiling +echo "" +echo "📸 Starting memory profiler (${DURATION}s)..." +echo "" +echo "🔥 START YOUR LOAD TEST IN POSTMAN NOW!" +echo " Collection: profiling/load-test-collection.json" +echo " Iterations: 10000-50000" +echo " Delay: 1ms" +echo "" +echo "⏱️ Profiling for ${DURATION} seconds..." + +# Convert seconds to proper format (use seconds directly) +docker exec ${CONTAINER_NAME} sh -c "./dotMemoryclt/tools/dotmemory attach 1 --timeout=${DURATION}s" + +# Wait a bit for snapshot to be saved +echo "" +echo "💾 Waiting for snapshot to be saved..." +sleep 3 + +# Find the latest snapshot file +LATEST_SNAPSHOT=$(docker exec ${CONTAINER_NAME} sh -c "ls -t /app/*.dmw 2>/dev/null | head -1" || echo "") + +if [ -z "$LATEST_SNAPSHOT" ]; then + echo "❌ Error: No snapshot file found" + echo "💡 Check container logs: docker logs ${CONTAINER_NAME}" + exit 1 +fi + +echo "📦 Snapshot created: ${LATEST_SNAPSHOT}" + +# Copy snapshot to host +echo "📂 Copying snapshot to ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}..." +mkdir -p "${SNAPSHOT_DIR}" +docker cp "${CONTAINER_NAME}:${LATEST_SNAPSHOT}" "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" + +# Get file size +FILE_SIZE=$(du -h "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" | cut -f1) + +echo "" +echo "✅ Automated memory profiling completed!" +echo "================================================" +echo "📊 Snapshot: ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" +echo "📏 Size: ${FILE_SIZE}" +echo "" +echo "💡 Open in dotMemory:" +echo " File → Open → ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" diff --git a/profiling/memory/memory-snapshot-under-load.sh b/profiling/memory/memory-snapshot-under-load.sh new file mode 100755 index 00000000..7a8330e8 --- /dev/null +++ b/profiling/memory/memory-snapshot-under-load.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# Memory Snapshot Under Load Script for ServerEye Backend +# Creates memory snapshot with 60 seconds profiling under load + +set -e + +CONTAINER_NAME="ServerEyeWeb-backend-dev" +SNAPSHOT_DIR="/home/gospodin/Desktop/homeProjects/ServerEyeProjects/ServerEyeWeb/profiling/snapshots/memory" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +SNAPSHOT_NAME="memory-snapshot-load-${TIMESTAMP}.dmw" +DURATION=${1:-60} # Default 60 seconds, can be overridden + +echo "🔍 Starting memory profiling under load for ${CONTAINER_NAME}..." +echo "⏱️ Duration: ${DURATION} seconds" + +# Check if container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "❌ Error: Container ${CONTAINER_NAME} is not running" + exit 1 +fi + +# Check if dotMemory CLI is installed in container +echo "📦 Checking dotMemory CLI installation..." +if ! docker exec ${CONTAINER_NAME} test -d ./dotMemoryclt/tools; then + echo "📥 Installing dotMemory CLI..." + docker exec ${CONTAINER_NAME} sh -c " + apt-get update -y && apt-get install -y wget unzip && \ + wget -O dotMemoryclt.zip https://www.nuget.org/api/v2/package/JetBrains.dotMemory.Console.linux-x64 && \ + unzip -q dotMemoryclt.zip -d ./dotMemoryclt && \ + chmod +x -R dotMemoryclt/* + " + echo "✅ dotMemory CLI installed" +else + echo "✅ dotMemory CLI already installed" +fi + +# Create snapshot with profiling +echo "📸 Creating memory snapshot with ${DURATION}s profiling..." +echo "💡 Start your load test in Postman NOW!" +echo "" + +docker exec ${CONTAINER_NAME} sh -c "./dotMemoryclt/tools/dotmemory attach 1 --timeout=00:0${DURATION}:00" + +# Find the latest snapshot file +LATEST_SNAPSHOT=$(docker exec ${CONTAINER_NAME} sh -c "ls -t /app/*.dmw | head -1") + +if [ -z "$LATEST_SNAPSHOT" ]; then + echo "❌ Error: No snapshot file found" + exit 1 +fi + +echo "📦 Snapshot created: ${LATEST_SNAPSHOT}" + +# Copy snapshot to host +echo "📂 Copying snapshot to ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}..." +mkdir -p "${SNAPSHOT_DIR}" +docker cp "${CONTAINER_NAME}:${LATEST_SNAPSHOT}" "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" + +# Get file size +FILE_SIZE=$(du -h "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" | cut -f1) + +echo "✅ Memory snapshot under load completed!" +echo "📊 Snapshot saved: ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" +echo "📏 Size: ${FILE_SIZE}" +echo "" +echo "💡 Open in dotMemory: File → Open → ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" diff --git a/profiling/memory/memory-snapshot.sh b/profiling/memory/memory-snapshot.sh new file mode 100755 index 00000000..41f77848 --- /dev/null +++ b/profiling/memory/memory-snapshot.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +# Memory Snapshot Script for ServerEye Backend +# Automatically creates memory snapshot and moves it to snapshots directory + +set -e + +CONTAINER_NAME="ServerEyeWeb-backend-dev" +SNAPSHOT_DIR="/home/gospodin/Desktop/homeProjects/ServerEyeProjects/ServerEyeWeb/profiling/snapshots/memory" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +SNAPSHOT_NAME="memory-snapshot-${TIMESTAMP}.dmw" + +echo "🔍 Starting memory profiling for ${CONTAINER_NAME}..." + +# Check if container is running +if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then + echo "❌ Error: Container ${CONTAINER_NAME} is not running" + exit 1 +fi + +# Check if dotMemory CLI is installed in container +echo "📦 Checking dotMemory CLI installation..." +if ! docker exec ${CONTAINER_NAME} test -d ./dotMemoryclt/tools; then + echo "📥 Installing dotMemory CLI..." + docker exec ${CONTAINER_NAME} sh -c " + apt-get update -y && apt-get install -y wget unzip && \ + wget -O dotMemoryclt.zip https://www.nuget.org/api/v2/package/JetBrains.dotMemory.Console.linux-x64 && \ + unzip -q dotMemoryclt.zip -d ./dotMemoryclt && \ + chmod +x -R dotMemoryclt/* + " + echo "✅ dotMemory CLI installed" +else + echo "✅ dotMemory CLI already installed" +fi + +# Create snapshot +echo "📸 Creating memory snapshot..." +docker exec ${CONTAINER_NAME} sh -c "./dotMemoryclt/tools/dotmemory get-snapshot 1" + +# Find the latest snapshot file +LATEST_SNAPSHOT=$(docker exec ${CONTAINER_NAME} sh -c "ls -t /app/*.dmw | head -1") + +if [ -z "$LATEST_SNAPSHOT" ]; then + echo "❌ Error: No snapshot file found" + exit 1 +fi + +echo "📦 Snapshot created: ${LATEST_SNAPSHOT}" + +# Copy snapshot to host +echo "📂 Copying snapshot to ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}..." +mkdir -p "${SNAPSHOT_DIR}" +docker cp "${CONTAINER_NAME}:${LATEST_SNAPSHOT}" "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" + +# Get file size +FILE_SIZE=$(du -h "${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" | cut -f1) + +echo "✅ Memory snapshot completed!" +echo "📊 Snapshot saved: ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}" +echo "📏 Size: ${FILE_SIZE}" +echo "" +echo "💡 Open in dotMemory: File → Open → ${SNAPSHOT_DIR}/${SNAPSHOT_NAME}"