diff --git a/.github/workflows/Build-Test-And-Deploy.yml b/.github/workflows/Build-Test-And-Deploy.yml index 7b2fdf84..7e4f36f5 100644 --- a/.github/workflows/Build-Test-And-Deploy.yml +++ b/.github/workflows/Build-Test-And-Deploy.yml @@ -166,7 +166,7 @@ jobs: az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \ Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \ AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Staging \ - AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \ + AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ TryDotNet__Origin=$TRYDOTNET_ORIGIN @@ -263,7 +263,7 @@ jobs: az containerapp update --name $CONTAINER_APP_NAME --resource-group $RESOURCEGROUP --replace-env-vars Authentication__github__clientId=secretref:github-clientid Authentication__github__clientSecret=secretref:github-clientsecret \ Authentication__microsoft__clientId=secretref:msft-clientid Authentication__microsoft__clientSecret=secretref:msft-clientsecret AuthMessageSender__ApiKey=secretref:emailsender-apikey AuthMessageSender__SecretKey=secretref:emailsender-secret \ AuthMessageSender__SendFromName=secretref:emailsender-name AuthMessageSender__SendFromEmail=secretref:emailsender-email ConnectionStrings__EssentialCSharpWebContextConnection=secretref:connectionstring ASPNETCORE_ENVIRONMENT=Production \ - AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey ApplicationInsights__ConnectionString=secretref:appinsights-connectionstring \ + AZURE_CLIENT_ID=$AZURECLIENTID HCaptcha__SiteKey=secretref:captcha-sitekey HCaptcha__SecretKey=secretref:captcha-secretkey APPLICATIONINSIGHTS_CONNECTION_STRING=secretref:appinsights-connectionstring \ AIOptions__Endpoint=secretref:ai-endpoint AIOptions__ApiKey=secretref:ai-apikey AIOptions__VectorGenerationDeploymentName=secretref:ai-vectordeployment AIOptions__ChatDeploymentName=secretref:ai-chatdeployment \ AIOptions__SystemPrompt=secretref:ai-systemprompt ConnectionStrings__PostgresVectorStore=secretref:postgres-vectorstore-connectionstring \ TryDotNet__Origin=$TRYDOTNET_ORIGIN diff --git a/Directory.Packages.props b/Directory.Packages.props index d42d6879..77a6e51e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,6 @@ - @@ -39,9 +38,13 @@ + - + + + + @@ -51,6 +54,12 @@ + + + + + + diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index c6498e3e..82719d8a 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -101,8 +101,10 @@ public static IServiceCollection AddAzureOpenAIServices( /// /// Adds PostgreSQL vector store with managed identity authentication support. - /// NOTE: Token is obtained once at startup and will expire after ~1 hour. - /// For long-running applications, consider implementing token refresh logic. + /// Uses per-connection token refresh via UsePasswordProvider, which calls + /// on every new physical connection. + /// caches tokens internally and auto-refreshes + /// ~5 minutes before expiry, so this does not add Azure AD overhead. /// /// The service collection to add services to /// The PostgreSQL connection string (without password) @@ -115,36 +117,51 @@ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity( { credential ??= new DefaultAzureCredential(); - // Parse the connection string to extract host, database, and username - var builder = new NpgsqlConnectionStringBuilder(connectionString); - - // Check if this is an Azure PostgreSQL connection (contains .postgres.database.azure.com) - bool isAzurePostgres = builder.Host?.Contains(".postgres.database.azure.com", StringComparison.OrdinalIgnoreCase) ?? false; - - if (isAzurePostgres && string.IsNullOrEmpty(builder.Password)) - { - // Get access token for Azure PostgreSQL using managed identity - var tokenRequestContext = new TokenRequestContext(_PostgresScopes); - var accessToken = credential.GetToken(tokenRequestContext, default); - - // Set the password to the access token - builder.Password = accessToken.Token; - - // Ensure SSL is enabled for Azure - if (builder.SslMode == SslMode.Disable) - { - builder.SslMode = SslMode.Require; - } - - connectionString = builder.ToString(); - } - // Register NpgsqlDataSource with UseVector() enabled - this is critical for pgvector support services.AddSingleton(sp => { + var connBuilder = new NpgsqlConnectionStringBuilder(connectionString); + bool isAzurePostgres = connBuilder.Host?.Contains(".postgres.database.azure.com", + StringComparison.OrdinalIgnoreCase) ?? false; + var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString); // IMPORTANT: UseVector() must be called to enable pgvector support dataSourceBuilder.UseVector(); + + if (isAzurePostgres && string.IsNullOrEmpty(connBuilder.Password)) + { + // Ensure SSL is enabled for Azure PostgreSQL + if (dataSourceBuilder.ConnectionStringBuilder.SslMode < SslMode.Require) + { + dataSourceBuilder.ConnectionStringBuilder.SslMode = SslMode.Require; + } + + var tokenRequestContext = new TokenRequestContext(_PostgresScopes); + + // UsePasswordProvider is called for every new physical connection. + // DefaultAzureCredential caches tokens internally and auto-refreshes ~5 min before + // expiry — no extra Azure AD load. This is the approach recommended by the Npgsql + // docs for cloud providers that implement their own caching (Azure MI does). + // UsePeriodicPasswordProvider is only for token sources without built-in caching. + // See: https://www.npgsql.org/doc/security.html + // See: https://github.com/npgsql/npgsql/issues/5186 + // + // Note: The username is expected to be set in the connection string already + // (Aspire sets it during deployment for Azure PostgreSQL Flexible Server). + // If a standalone username-extraction fallback is ever needed, use the + // Microsoft.Azure.PostgreSQL.Auth package (UseEntraAuthentication extension) + // once it ships on NuGet. + dataSourceBuilder.UsePasswordProvider( + passwordProvider: _ => credential.GetToken(tokenRequestContext, default).Token, + passwordProviderAsync: async (_, ct) => + (await credential.GetTokenAsync(tokenRequestContext, ct)).Token); + + // Recycle pooled connections after 50 min, well before the 60-min JWT token TTL. + // Combined with UsePasswordProvider (called on every new physical connection), + // this ensures no pooled connection ever holds an expired token. + dataSourceBuilder.ConnectionStringBuilder.ConnectionLifetime = 3000; + } + return dataSourceBuilder.Build(); }); diff --git a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs index 3048e753..0474ce92 100644 --- a/EssentialCSharp.Chat.Shared/Services/AIChatService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AIChatService.cs @@ -118,14 +118,14 @@ private async Task EnrichPromptWithContext(string prompt, bool enableCon return prompt; } - var searchResults = await _SearchService.ExecuteVectorSearch(prompt); + var searchResults = await _SearchService.ExecuteVectorSearch(prompt, cancellationToken: cancellationToken); var contextualInfo = new System.Text.StringBuilder(); contextualInfo.AppendLine("## Contextual Information"); contextualInfo.AppendLine("The following information might be relevant to your question:"); contextualInfo.AppendLine(); - await foreach (var result in searchResults) + foreach (var result in searchResults) { contextualInfo.AppendLine(System.Globalization.CultureInfo.InvariantCulture, $"**From: {result.Record.Heading}**"); contextualInfo.AppendLine(result.Record.ChunkText); diff --git a/EssentialCSharp.Chat.Shared/Services/AISearchService.cs b/EssentialCSharp.Chat.Shared/Services/AISearchService.cs index 915d1dc4..37f21b09 100644 --- a/EssentialCSharp.Chat.Shared/Services/AISearchService.cs +++ b/EssentialCSharp.Chat.Shared/Services/AISearchService.cs @@ -1,27 +1,54 @@ -using EssentialCSharp.Chat.Common.Models; +using System.Diagnostics; +using EssentialCSharp.Chat.Common.Models; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.VectorData; +using Npgsql; namespace EssentialCSharp.Chat.Common.Services; -public class AISearchService(VectorStore vectorStore, EmbeddingService embeddingService) +public class AISearchService( + VectorStore vectorStore, + EmbeddingService embeddingService, + ILogger logger) { // TODO: Implement Hybrid Search functionality, may need to switch db providers to support full text search? - public async Task>> ExecuteVectorSearch(string query, string? collectionName = null) + public async Task>> ExecuteVectorSearch( + string query, string? collectionName = null, CancellationToken cancellationToken = default) { collectionName ??= EmbeddingService.CollectionName; VectorStoreCollection collection = vectorStore.GetCollection(collectionName); - ReadOnlyMemory searchVector = await embeddingService.GenerateEmbeddingAsync(query); + ReadOnlyMemory searchVector = await embeddingService.GenerateEmbeddingAsync(query, cancellationToken); var vectorSearchOptions = new VectorSearchOptions { VectorProperty = x => x.TextEmbedding, }; - var searchResults = collection.SearchAsync(searchVector, options: vectorSearchOptions, top: 3); - - return searchResults; + for (int attempt = 0; attempt <= 1; attempt++) + { + try + { + var results = new List>(); + await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: 3, cancellationToken: cancellationToken)) + { + results.Add(result); + } + return results; + } + catch (PostgresException ex) when (ex.SqlState == "28000" && attempt == 0) + { + // The pooled connection held an expired Entra ID token. Npgsql automatically + // removes the broken connection from the pool on error — no manual pool clearing + // needed (clearing would evict all healthy connections, hurting concurrent users). + // The retry opens a fresh physical connection, which calls UsePasswordProvider + // and gets a new token from DefaultAzureCredential. + logger.LogWarning(ex, "Entra ID token expired on pooled connection (SqlState 28000); retrying once."); + } + } + + throw new UnreachableException("Retry loop exited without returning or throwing."); } } diff --git a/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs b/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs new file mode 100644 index 00000000..9e0366a4 --- /dev/null +++ b/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs @@ -0,0 +1,109 @@ +using EssentialCSharp.Chat.Common.Models; +using EssentialCSharp.Chat.Common.Services; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.VectorData; +using Moq; +using Moq.Language.Flow; +using Npgsql; + +namespace EssentialCSharp.Chat.Tests; + +public class AISearchServiceTests +{ + private static readonly BookContentChunk _TestChunk = new() { Id = "test-1", ChunkText = "test" }; + + private static (AISearchService svc, Mock> collectionMock) + CreateService() + { + var collectionMock = new Mock>(); + + var vectorStoreMock = new Mock(); + vectorStoreMock + .Setup(vs => vs.GetCollection(It.IsAny(), It.IsAny())) + .Returns(collectionMock.Object); + + // IEmbeddingGenerator>.GenerateAsync is the batch interface method + // called internally by the single-value extension used in EmbeddingService.GenerateEmbeddingAsync. + var embGenMock = new Mock>>(); + embGenMock + .Setup(g => g.GenerateAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new GeneratedEmbeddings>([new Embedding(new float[1536])])); + + var embeddingService = new EmbeddingService(vectorStoreMock.Object, embGenMock.Object); + var loggerMock = new Mock>(); + + return (new AISearchService(vectorStoreMock.Object, embeddingService, loggerMock.Object), collectionMock); + } + + private static async IAsyncEnumerable> OneResultStream() + { + yield return new VectorSearchResult(_TestChunk, 0.9f); + await Task.CompletedTask; + } + + private static ISetup, IAsyncEnumerable>> + SetupSearch(Mock> mock) => + mock.Setup(c => c.SearchAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())); + + [Test] + public async Task ExecuteVectorSearch_HappyPath_ReturnsResultsWithoutRetry() + { + var (svc, collectionMock) = CreateService(); + int callCount = 0; + + SetupSearch(collectionMock).Returns(() => { callCount++; return OneResultStream(); }); + + var results = await svc.ExecuteVectorSearch("test query"); + + await Assert.That(results.Count).IsEqualTo(1); + await Assert.That(callCount).IsEqualTo(1); + } + + [Test] + public async Task ExecuteVectorSearch_RetriesOnce_WhenFirstAttemptThrows28000() + { + var (svc, collectionMock) = CreateService(); + int callCount = 0; + + SetupSearch(collectionMock).Returns(() => + { + callCount++; + if (callCount == 1) + throw new PostgresException("auth token expired", "FATAL", "FATAL", "28000"); + return OneResultStream(); + }); + + var results = await svc.ExecuteVectorSearch("test query"); + + await Assert.That(results.Count).IsEqualTo(1); + await Assert.That(callCount).IsEqualTo(2); + } + + [Test] + public async Task ExecuteVectorSearch_DoesNotRetry_WhenSqlStateIsNot28000() + { + var (svc, collectionMock) = CreateService(); + + SetupSearch(collectionMock).Returns(() => throw new PostgresException("table not found", "ERROR", "ERROR", "42P01")); + + await Assert.ThrowsAsync(() => svc.ExecuteVectorSearch("test query")); + } + + [Test] + public async Task ExecuteVectorSearch_PropagatesException_WhenBothAttemptsFail28000() + { + var (svc, collectionMock) = CreateService(); + + SetupSearch(collectionMock).Returns(() => throw new PostgresException("auth failed", "FATAL", "FATAL", "28000")); + + await Assert.ThrowsAsync(() => svc.ExecuteVectorSearch("test query")); + } +} diff --git a/EssentialCSharp.Web.Tests/FunctionalTests.cs b/EssentialCSharp.Web.Tests/FunctionalTests.cs index 78edc27a..31aa723e 100644 --- a/EssentialCSharp.Web.Tests/FunctionalTests.cs +++ b/EssentialCSharp.Web.Tests/FunctionalTests.cs @@ -11,7 +11,8 @@ public class FunctionalTests(WebApplicationFactory factory) [Arguments("/hello-world")] [Arguments("/hello-world#hello-world")] [Arguments("/guidelines")] - [Arguments("/healthz")] + [Arguments("/health")] + [Arguments("/alive")] public async Task WhenTheApplicationStarts_ItCanLoadLoadPages(string relativeUrl) { HttpClient client = factory.CreateClient(); diff --git a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs index 873901cd..83a413c0 100644 --- a/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs +++ b/EssentialCSharp.Web.Tests/ListingSourceCodeServiceTests.cs @@ -1,9 +1,5 @@ using EssentialCSharp.Web.Models; using EssentialCSharp.Web.Services; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.FileProviders; -using Moq; -using Moq.AutoMock; namespace EssentialCSharp.Web.Tests; @@ -130,30 +126,6 @@ public async Task GetListingsByChapterAsync_WithInvalidChapter_ReturnsEmptyList( await Assert.That(results).IsEmpty(); } - private static ListingSourceCodeService CreateService() - { - DirectoryInfo testDataRoot = GetTestDataPath(); - - AutoMocker mocker = new(); - Mock mockWebHostEnvironment = mocker.GetMock(); - mockWebHostEnvironment.Setup(m => m.ContentRootPath).Returns(testDataRoot.FullName); - mockWebHostEnvironment.Setup(m => m.ContentRootFileProvider).Returns(new PhysicalFileProvider(testDataRoot.FullName)); - - return mocker.CreateInstance(); - } - - private static DirectoryInfo GetTestDataPath() - { - string baseDirectory = AppContext.BaseDirectory; - string testDataPath = Path.Join(baseDirectory, "TestData"); - - DirectoryInfo testDataDirectory = new(testDataPath); - - if (!testDataDirectory.Exists) - { - throw new InvalidOperationException($"TestData directory not found at: {testDataDirectory.FullName}"); - } - - return testDataDirectory; - } + private static ListingSourceCodeService CreateService() => + TestListingSourceCodeServiceHelper.CreateService(); } diff --git a/EssentialCSharp.Web.Tests/TestListingSourceCodeServiceHelper.cs b/EssentialCSharp.Web.Tests/TestListingSourceCodeServiceHelper.cs new file mode 100644 index 00000000..a37d32fa --- /dev/null +++ b/EssentialCSharp.Web.Tests/TestListingSourceCodeServiceHelper.cs @@ -0,0 +1,29 @@ +using EssentialCSharp.Web.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; +using Moq.AutoMock; + +namespace EssentialCSharp.Web.Tests; + +internal static class TestListingSourceCodeServiceHelper +{ + internal static string ResolveTestDataPath() + { + string testDataPath = Path.Join(AppContext.BaseDirectory, "TestData"); + if (!Directory.Exists(testDataPath)) + throw new InvalidOperationException($"TestData directory not found at: {testDataPath}"); + return testDataPath; + } + + internal static ListingSourceCodeService CreateService() + { + string testDataPath = ResolveTestDataPath(); + + AutoMocker mocker = new(); + mocker.Setup(m => m.ContentRootPath).Returns(testDataPath); + mocker.Setup(m => m.ContentRootFileProvider) + .Returns(new PhysicalFileProvider(testDataPath)); + + return mocker.CreateInstance(); + } +} diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 8816bf4b..287150ee 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -9,8 +9,6 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.FileProviders; -using Moq.AutoMock; namespace EssentialCSharp.Web.Tests; @@ -51,6 +49,15 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.Remove(dbConnectionDescriptor); } + // Remove DatabaseMigrationService: it calls MigrateAsync which conflicts + // with EnsureCreated() used below for the in-memory SQLite test database. + ServiceDescriptor? migrationServiceDescriptor = services.SingleOrDefault( + d => d.ImplementationType == typeof(DatabaseMigrationService)); + if (migrationServiceDescriptor != null) + { + services.Remove(migrationServiceDescriptor); + } + _Connection = new SqliteConnection(SqlConnectionString); _Connection.Open(); @@ -68,16 +75,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) // Replace IListingSourceCodeService with one backed by TestData services.RemoveAll(); - - string testDataPath = Path.Join(AppContext.BaseDirectory, "TestData"); - var fileProvider = new PhysicalFileProvider(testDataPath); - services.AddSingleton(sp => - { - var mocker = new AutoMocker(); - mocker.Setup(m => m.ContentRootPath).Returns(testDataPath); - mocker.Setup(m => m.ContentRootFileProvider).Returns(fileProvider); - return mocker.CreateInstance(); - }); + services.AddSingleton( + _ => TestListingSourceCodeServiceHelper.CreateService()); }); } diff --git a/EssentialCSharp.Web/DatabaseMigrationService.cs b/EssentialCSharp.Web/DatabaseMigrationService.cs index d61ed698..09e87a9e 100644 --- a/EssentialCSharp.Web/DatabaseMigrationService.cs +++ b/EssentialCSharp.Web/DatabaseMigrationService.cs @@ -10,15 +10,7 @@ public class DatabaseMigrationService(IServiceScopeFactory services) : Backgroun protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using IServiceScope scope = Services.CreateScope(); - EssentialCSharpWebContext? context = scope.ServiceProvider.GetRequiredService() - ?? throw new InvalidOperationException($"EssentialCSharpWebContext not found for {nameof(DatabaseMigrationService)}"); - if (!context.Database.GetPendingMigrations().Contains("20231021170008_CreateIdentitySchema")) - { - await context.Database.MigrateAsync(stoppingToken); - } - else - { - await context.Database.EnsureCreatedAsync(cancellationToken: stoppingToken); - } + EssentialCSharpWebContext context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(stoppingToken); } } diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 519a7c14..5683bc0b 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -1,11 +1,12 @@ - + net10.0 - $(NoWarn);CA1873 + + $(NoWarn);CA1873; @@ -27,15 +28,14 @@ - + - @@ -43,11 +43,25 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + + + all + + + + + + + diff --git a/EssentialCSharp.Web/Extensions/LoggerExtensions.cs b/EssentialCSharp.Web/Extensions/LoggerExtensions.cs deleted file mode 100644 index aa183e8b..00000000 --- a/EssentialCSharp.Web/Extensions/LoggerExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.AspNetCore.Mvc; - -namespace EssentialCSharp.Web.Extensions; - -internal static partial class LoggerExtensions -{ - [LoggerMessage(Level = LogLevel.Debug, EventId = 1, Message = "Successful captcha with response of: '{JsonResult}'")] - public static partial void HomeControllerSuccessfulCaptchaResponse( - this ILogger logger, JsonResult jsonResult); -} diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 5df9f38c..47044ebd 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -1,5 +1,4 @@ using System.Threading.RateLimiting; -using Azure.Monitor.OpenTelemetry.AspNetCore; using EssentialCSharp.Chat.Common.Extensions; using EssentialCSharp.Web.Areas.Identity.Data; using EssentialCSharp.Web.Areas.Identity.Services.PasswordValidators; @@ -14,7 +13,14 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.RateLimiting; +using Azure.Monitor.OpenTelemetry.AspNetCore; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using OpenTelemetry; +using OpenTelemetry.Instrumentation.AspNetCore; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; namespace EssentialCSharp.Web; @@ -24,6 +30,66 @@ private static void Main(string[] args) { WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + // Health checks (liveness/readiness probes for ACA and standalone hosting) + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + // OpenTelemetry — two mutually exclusive export paths: + // Production: Azure Monitor (Application Insights) via APPLICATIONINSIGHTS_CONNECTION_STRING + // Local/Aspire: OTLP to Aspire Dashboard via OTEL_EXPORTER_OTLP_ENDPOINT + // Never both simultaneously — that would cause duplicate telemetry in App Insights. + string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + bool useAzureMonitor = !string.IsNullOrWhiteSpace(appInsightsConnectionString); + bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + // Health probe paths excluded from tracing unconditionally — applies to both + // manual instrumentation and Azure Monitor's auto-instrumentation. + builder.Services.Configure(options => + options.Filter = ctx => + !ctx.Request.Path.StartsWithSegments("/health") + && !ctx.Request.Path.StartsWithSegments("/alive")); + + var otel = builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + // Azure Monitor auto-instruments ASP.NET Core + HttpClient metrics; only add + // them manually when using OTLP so we don't register duplicate meter listeners. + if (!useAzureMonitor) + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + } + // Runtime metrics are not included in the Azure Monitor distro. + metrics.AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName); + // Azure Monitor distro auto-instruments tracing; add manually only for OTLP path. + if (!useAzureMonitor) + { + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation(); + } + }); + + if (useAzureMonitor) + otel.UseAzureMonitor(); + else if (useOtlp) + otel.UseOtlpExporter(); + + // HttpClient defaults — standard retry/circuit breaker for all named clients. + builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); + + + builder.Services.Configure(options => { options.ForwardedHeaders = @@ -39,37 +105,12 @@ private static void Main(string[] args) ConfigurationManager configuration = builder.Configuration; string connectionString = builder.Configuration.GetConnectionString("EssentialCSharpWebContextConnection") ?? throw new InvalidOperationException("Connection string 'EssentialCSharpWebContextConnection' not found."); - builder.Logging.AddConsole(); - builder.Services.AddHealthChecks(); - // Create a logger that's accessible throughout the entire method var loggerFactory = LoggerFactory.Create(loggingBuilder => loggingBuilder.AddConsole().SetMinimumLevel(LogLevel.Information)); var initialLogger = loggerFactory.CreateLogger(); - if (!builder.Environment.IsDevelopment()) - { - // Configure Azure Application Insights with OpenTelemetry only if connection string is available - var appInsightsConnectionString = builder.Configuration.GetConnectionString("ApplicationInsights") - ?? builder.Configuration["ApplicationInsights:ConnectionString"]; - - if (!string.IsNullOrEmpty(appInsightsConnectionString)) - { - builder.Services.AddOpenTelemetry().UseAzureMonitor( - options => - { - options.ConnectionString = appInsightsConnectionString; - }); - builder.Services.AddApplicationInsightsTelemetry(); - builder.Services.AddServiceProfiler(); - } - else - { - initialLogger.LogWarning("Application Insights connection string not found. Telemetry collection will be disabled."); - } - } - - builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); + builder.Services.AddDbContext(options => options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5))); builder.Services.AddDefaultIdentity(options => { // Password settings @@ -289,7 +330,11 @@ await context.HttpContext.Response.WriteAsync( app.UseForwardedHeaders(); } - app.MapHealthChecks("/healthz"); + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); app.UseHttpsRedirection(); app.UseStaticFiles(); diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs index e81c14e9..a586023f 100644 --- a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs +++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs @@ -5,22 +5,43 @@ namespace EssentialCSharp.Web.Services; -public partial class ListingSourceCodeService : IListingSourceCodeService +public partial class ListingSourceCodeService : IListingSourceCodeService, IDisposable { - private readonly IWebHostEnvironment _WebHostEnvironment; + private readonly IFileProvider _FileProvider; + private readonly bool _OwnsFileProvider; + private readonly string _ChapterDirectoryPrefix; private readonly ILogger _Logger; - public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger logger) + public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger logger, IConfiguration configuration) { - _WebHostEnvironment = webHostEnvironment; _Logger = logger; + + string? listingSourceCodePath = configuration["LISTING_SOURCE_CODE_PATH"]; + if (!string.IsNullOrEmpty(listingSourceCodePath) && Directory.Exists(listingSourceCodePath)) + { + _FileProvider = new PhysicalFileProvider(listingSourceCodePath); + _OwnsFileProvider = true; + _ChapterDirectoryPrefix = "src"; + _Logger.LogInformation("Using listing source code from: {Path}", listingSourceCodePath); + } + else + { + _FileProvider = webHostEnvironment.ContentRootFileProvider; + _ChapterDirectoryPrefix = "ListingSourceCode/src"; + } + } + + public void Dispose() + { + if (_OwnsFileProvider && _FileProvider is IDisposable disposable) + disposable.Dispose(); + GC.SuppressFinalize(this); } public async Task GetListingAsync(int chapterNumber, int listingNumber) { - string chapterDirectory = $"ListingSourceCode/src/Chapter{chapterNumber:D2}"; - IFileProvider fileProvider = _WebHostEnvironment.ContentRootFileProvider; - IDirectoryContents directoryContents = fileProvider.GetDirectoryContents(chapterDirectory); + string chapterDirectory = $"{_ChapterDirectoryPrefix}/Chapter{chapterNumber:D2}"; + IDirectoryContents directoryContents = _FileProvider.GetDirectoryContents(chapterDirectory); if (!directoryContents.Exists) { @@ -52,9 +73,8 @@ public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger< public async Task> GetListingsByChapterAsync(int chapterNumber) { - string chapterDirectory = $"ListingSourceCode/src/Chapter{chapterNumber:D2}"; - IFileProvider fileProvider = _WebHostEnvironment.ContentRootFileProvider; - IDirectoryContents directoryContents = fileProvider.GetDirectoryContents(chapterDirectory); + string chapterDirectory = $"{_ChapterDirectoryPrefix}/Chapter{chapterNumber:D2}"; + IDirectoryContents directoryContents = _FileProvider.GetDirectoryContents(chapterDirectory); if (!directoryContents.Exists) { diff --git a/docs/getting-started.md b/docs/getting-started.md index 30dd9e7c..75a12802 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -5,7 +5,7 @@ This guide will help you set up your local development environment for working o ## What You Will Need - [Visual Studio](https://visualstudio.microsoft.com/) (or your preferred IDE) -- [.NET 9.0 SDK](https://dotnet.microsoft.com/download) +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download) - If you already have .NET installed you can check the version by typing `dotnet --info` into cmd to make sure you have the right version ## Startup Steps