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