From 9f9b3db6604d18ad8d4a714f53719530157a994c Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 08:44:34 -0700 Subject: [PATCH 1/9] wip improvements --- Directory.Packages.props | 7 ++ .../Extensions/ServiceCollectionExtensions.cs | 54 +++++++------ .../WebApplicationFactory.cs | 9 +++ .../DatabaseMigrationService.cs | 12 +-- .../EssentialCSharp.Web.csproj | 12 ++- EssentialCSharp.Web/Program.cs | 81 ++++++++++++------- docs/getting-started.md | 2 +- 7 files changed, 109 insertions(+), 68 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d42d6879..b1f0552d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,7 @@ + @@ -51,6 +52,12 @@ + + + + + + diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index c6498e3e..61e7cbe9 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -101,8 +101,7 @@ 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 periodic token refresh to ensure tokens are renewed before expiry. /// /// The service collection to add services to /// The PostgreSQL connection string (without password) @@ -115,36 +114,39 @@ 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; + } + + // Use periodic token refresh instead of a one-shot token at startup. + // Azure AD tokens expire after ~1 hour; refreshing every 50 minutes + // ensures uninterrupted connectivity for long-running applications. + dataSourceBuilder.UsePeriodicPasswordProvider( + async (_, ct) => + { + var tokenRequestContext = new TokenRequestContext(_PostgresScopes); + var accessToken = await credential.GetTokenAsync(tokenRequestContext, ct); + return accessToken.Token; + }, + TimeSpan.FromMinutes(50), + TimeSpan.FromSeconds(10)); + } + return dataSourceBuilder.Build(); }); diff --git a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs index 8816bf4b..58f312fe 100644 --- a/EssentialCSharp.Web.Tests/WebApplicationFactory.cs +++ b/EssentialCSharp.Web.Tests/WebApplicationFactory.cs @@ -51,6 +51,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(); 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..3a480259 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -1,4 +1,4 @@ - + net10.0 True 18e91e0d-ea2d-490f-b77e-ec008f9d09ec + + moderate diff --git a/Directory.Packages.props b/Directory.Packages.props index b1f0552d..b30ca28c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,6 @@ - @@ -52,12 +51,12 @@ - - - + + + - + diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 3a480259..ee3ce2db 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -5,7 +5,9 @@ CA1873: Logging argument evaluation - suppress for now; affects 14+ scaffolded Identity pages. TODO: Address by converting to LoggerMessage source generators in a follow-up. --> - $(NoWarn);CA1873 + + $(NoWarn);CA1873;LOGGEN036 diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 288130a2..78bee728 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -18,6 +18,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using OpenTelemetry; +using OpenTelemetry.Instrumentation.AspNetCore; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; @@ -33,37 +34,55 @@ private static void Main(string[] args) builder.Services.AddHealthChecks() .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - // OpenTelemetry — Aspire injects OTEL_EXPORTER_OTLP_ENDPOINT when hosted. - // Azure Monitor is enabled in production when APPLICATIONINSIGHTS_CONNECTION_STRING is set. + // 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. bool useAzureMonitor = !string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]); + bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + builder.Logging.AddOpenTelemetry(logging => { logging.IncludeFormattedMessage = true; logging.IncludeScopes = true; }); - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => metrics - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation()) + + // 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(t => - t.Filter = ctx => - !ctx.Request.Path.StartsWithSegments("/health") - && !ctx.Request.Path.StartsWithSegments("/alive")) - .AddHttpClientInstrumentation() - .AddSqlClientInstrumentation(); + tracing.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSqlClientInstrumentation(); } }); - if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) - builder.Services.AddOpenTelemetry().UseOtlpExporter(); + if (useAzureMonitor) - builder.Services.AddOpenTelemetry().UseAzureMonitor(); + otel.UseAzureMonitor(); + else if (useOtlp) + otel.UseOtlpExporter(); // HttpClient defaults — standard retry/circuit breaker for all named clients. builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler()); diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs index e81c14e9..7b4d9c8c 100644 --- a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs +++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs @@ -7,20 +7,32 @@ namespace EssentialCSharp.Web.Services; public partial class ListingSourceCodeService : IListingSourceCodeService { - private readonly IWebHostEnvironment _WebHostEnvironment; + private readonly IFileProvider _FileProvider; + 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); + _ChapterDirectoryPrefix = "src"; + _Logger.LogInformation("Using listing source code from: {Path}", listingSourceCodePath); + } + else + { + _FileProvider = webHostEnvironment.ContentRootFileProvider; + _ChapterDirectoryPrefix = "ListingSourceCode/src"; + } } 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 +64,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) { From c7945f3064d6feb4d1b19171a1a4681de51b1501 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 20:27:58 -0700 Subject: [PATCH 3/9] fix --- .../Extensions/ServiceCollectionExtensions.cs | 41 +++++++++++++------ .../Services/AIChatService.cs | 4 +- .../Services/AISearchService.cs | 41 +++++++++++++++---- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs index 61e7cbe9..82719d8a 100644 --- a/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs +++ b/EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs @@ -101,7 +101,10 @@ public static IServiceCollection AddAzureOpenAIServices( /// /// Adds PostgreSQL vector store with managed identity authentication support. - /// Uses periodic token refresh to ensure tokens are renewed before expiry. + /// 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) @@ -133,18 +136,30 @@ private static IServiceCollection AddPostgresVectorStoreWithManagedIdentity( dataSourceBuilder.ConnectionStringBuilder.SslMode = SslMode.Require; } - // Use periodic token refresh instead of a one-shot token at startup. - // Azure AD tokens expire after ~1 hour; refreshing every 50 minutes - // ensures uninterrupted connectivity for long-running applications. - dataSourceBuilder.UsePeriodicPasswordProvider( - async (_, ct) => - { - var tokenRequestContext = new TokenRequestContext(_PostgresScopes); - var accessToken = await credential.GetTokenAsync(tokenRequestContext, ct); - return accessToken.Token; - }, - TimeSpan.FromMinutes(50), - TimeSpan.FromSeconds(10)); + 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."); } } From fdc95251595ded56965919e831f8ed57223da1f9 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 20:29:49 -0700 Subject: [PATCH 4/9] update --- Directory.Build.props | 3 --- 1 file changed, 3 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 4a86c2d1..1a87d274 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,8 +11,5 @@ True 18e91e0d-ea2d-490f-b77e-ec008f9d09ec - - moderate From bfc11404d86b19ec33856a52b787ad1b0417d777 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 20:40:41 -0700 Subject: [PATCH 5/9] update --- Directory.Packages.props | 5 ++++- EssentialCSharp.Web/EssentialCSharp.Web.csproj | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b30ca28c..77a6e51e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -41,7 +41,10 @@ - + + + + diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index ee3ce2db..73d07c3b 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -46,6 +46,13 @@ + + + all + + + all + From 69b342c6e017c6cedffe854fba757410e5187b18 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 21:07:21 -0700 Subject: [PATCH 6/9] cleanup --- EssentialCSharp.Web/EssentialCSharp.Web.csproj | 5 ++--- EssentialCSharp.Web/Extensions/LoggerExtensions.cs | 10 ---------- 2 files changed, 2 insertions(+), 13 deletions(-) delete mode 100644 EssentialCSharp.Web/Extensions/LoggerExtensions.cs diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 73d07c3b..5683bc0b 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -5,9 +5,8 @@ CA1873: Logging argument evaluation - suppress for now; affects 14+ scaffolded Identity pages. TODO: Address by converting to LoggerMessage source generators in a follow-up. --> - - $(NoWarn);CA1873;LOGGEN036 + + $(NoWarn);CA1873; 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); -} From 91aa472ac9f4cbcb4095dd84cdf91b21e68ad88c Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 21:31:58 -0700 Subject: [PATCH 7/9] Fix PR review comments: health endpoint test, AppInsights config, FileProvider disposal, retry tests - Fix CI failure: update FunctionalTests.cs to use /health and /alive instead of removed /healthz - Fix AppInsights silent failure: check APPLICATIONINSIGHTS_CONNECTION_STRING, ApplicationInsights:ConnectionString, and GetConnectionString('ApplicationInsights') fallback; pass resolved string explicitly to UseAzureMonitor(options => ...) since the no-arg overload only auto-reads the flat env var. Deployment workflow sets ApplicationInsights__ConnectionString which maps to the hierarchical key, not the flat env var. - Fix PhysicalFileProvider IDisposable leak: ListingSourceCodeService implements IDisposable with _OwnsFileProvider tracking; only disposes the provider it creates, not the framework-managed ContentRootFileProvider; GC.SuppressFinalize included per CA1816. - Add AISearchService retry unit tests: 4 tests covering happy path (no retry), 28000 retry success, non-28000 pass-through, and both-attempts-fail propagation. PostgresException has a public constructor so no reflection or seams are needed. --- .../AISearchServiceTests.cs | 124 ++++++++++++++++++ EssentialCSharp.Web.Tests/FunctionalTests.cs | 3 +- EssentialCSharp.Web/Program.cs | 12 +- .../Services/ListingSourceCodeService.cs | 11 +- 4 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 EssentialCSharp.Chat.Tests/AISearchServiceTests.cs diff --git a/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs b/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs new file mode 100644 index 00000000..5b53bdc0 --- /dev/null +++ b/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs @@ -0,0 +1,124 @@ +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 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; + } + + [Test] + public async Task ExecuteVectorSearch_HappyPath_ReturnsResultsWithoutRetry() + { + var (svc, collectionMock) = CreateService(); + int callCount = 0; + + collectionMock + .Setup(c => c.SearchAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .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; + + collectionMock + .Setup(c => c.SearchAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .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(); + + collectionMock + .Setup(c => c.SearchAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .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(); + + collectionMock + .Setup(c => c.SearchAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .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/Program.cs b/EssentialCSharp.Web/Program.cs index 78bee728..54fb8669 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -38,7 +38,15 @@ private static void Main(string[] args) // 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. - bool useAzureMonitor = !string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]); + // Resolve from all config locations: + // 1. Standard Azure Monitor flat env var (SDK's own default key) + // 2. Double-underscore (hierarchical) format set by the deployment workflow + // 3. GetConnectionString fallback + string? appInsightsConnectionString = + builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] + ?? builder.Configuration["ApplicationInsights:ConnectionString"] + ?? builder.Configuration.GetConnectionString("ApplicationInsights"); + bool useAzureMonitor = !string.IsNullOrWhiteSpace(appInsightsConnectionString); bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); builder.Logging.AddOpenTelemetry(logging => @@ -80,7 +88,7 @@ private static void Main(string[] args) }); if (useAzureMonitor) - otel.UseAzureMonitor(); + otel.UseAzureMonitor(options => options.ConnectionString = appInsightsConnectionString); else if (useOtlp) otel.UseOtlpExporter(); diff --git a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs index 7b4d9c8c..a586023f 100644 --- a/EssentialCSharp.Web/Services/ListingSourceCodeService.cs +++ b/EssentialCSharp.Web/Services/ListingSourceCodeService.cs @@ -5,9 +5,10 @@ namespace EssentialCSharp.Web.Services; -public partial class ListingSourceCodeService : IListingSourceCodeService +public partial class ListingSourceCodeService : IListingSourceCodeService, IDisposable { private readonly IFileProvider _FileProvider; + private readonly bool _OwnsFileProvider; private readonly string _ChapterDirectoryPrefix; private readonly ILogger _Logger; @@ -19,6 +20,7 @@ public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger< if (!string.IsNullOrEmpty(listingSourceCodePath) && Directory.Exists(listingSourceCodePath)) { _FileProvider = new PhysicalFileProvider(listingSourceCodePath); + _OwnsFileProvider = true; _ChapterDirectoryPrefix = "src"; _Logger.LogInformation("Using listing source code from: {Path}", listingSourceCodePath); } @@ -29,6 +31,13 @@ public ListingSourceCodeService(IWebHostEnvironment webHostEnvironment, ILogger< } } + public void Dispose() + { + if (_OwnsFileProvider && _FileProvider is IDisposable disposable) + disposable.Dispose(); + GC.SuppressFinalize(this); + } + public async Task GetListingAsync(int chapterNumber, int listingNumber) { string chapterDirectory = $"{_ChapterDirectoryPrefix}/Chapter{chapterNumber:D2}"; From c9acf6376444fdf659e36eadbb97a9d5f798386b Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 21:40:18 -0700 Subject: [PATCH 8/9] refactor(tests): DRY up test infrastructure - Extract TestListingSourceCodeServiceHelper with ResolveTestDataPath() and CreateService() so both ListingSourceCodeServiceTests and WebApplicationFactory share the same TestData-backed ListingSourceCodeService setup. Also aligns failure behavior: both now throw a clear InvalidOperationException if the TestData directory is missing. - Add SetupSearch() helper in AISearchServiceTests that returns the ISetup for SearchAsync, removing the repeated 5-line Moq matcher block from all 4 test bodies. Each test still specifies its own .Returns(...) inline so retry behavior stays visible. --- .../AISearchServiceTests.cs | 53 +++++++------------ .../ListingSourceCodeServiceTests.cs | 32 +---------- .../TestListingSourceCodeServiceHelper.cs | 29 ++++++++++ .../WebApplicationFactory.cs | 14 +---- 4 files changed, 52 insertions(+), 76 deletions(-) create mode 100644 EssentialCSharp.Web.Tests/TestListingSourceCodeServiceHelper.cs diff --git a/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs b/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs index 5b53bdc0..9e0366a4 100644 --- a/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs +++ b/EssentialCSharp.Chat.Tests/AISearchServiceTests.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.VectorData; using Moq; +using Moq.Language.Flow; using Npgsql; namespace EssentialCSharp.Chat.Tests; @@ -44,19 +45,21 @@ private static async IAsyncEnumerable> OneR 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; - collectionMock - .Setup(c => c.SearchAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .Returns(() => { callCount++; return OneResultStream(); }); + SetupSearch(collectionMock).Returns(() => { callCount++; return OneResultStream(); }); var results = await svc.ExecuteVectorSearch("test query"); @@ -70,19 +73,13 @@ public async Task ExecuteVectorSearch_RetriesOnce_WhenFirstAttemptThrows28000() var (svc, collectionMock) = CreateService(); int callCount = 0; - collectionMock - .Setup(c => c.SearchAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .Returns(() => - { - callCount++; - if (callCount == 1) - throw new PostgresException("auth token expired", "FATAL", "FATAL", "28000"); - return OneResultStream(); - }); + 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"); @@ -95,13 +92,7 @@ public async Task ExecuteVectorSearch_DoesNotRetry_WhenSqlStateIsNot28000() { var (svc, collectionMock) = CreateService(); - collectionMock - .Setup(c => c.SearchAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .Returns(() => throw new PostgresException("table not found", "ERROR", "ERROR", "42P01")); + SetupSearch(collectionMock).Returns(() => throw new PostgresException("table not found", "ERROR", "ERROR", "42P01")); await Assert.ThrowsAsync(() => svc.ExecuteVectorSearch("test query")); } @@ -111,13 +102,7 @@ public async Task ExecuteVectorSearch_PropagatesException_WhenBothAttemptsFail28 { var (svc, collectionMock) = CreateService(); - collectionMock - .Setup(c => c.SearchAsync( - It.IsAny>(), - It.IsAny(), - It.IsAny?>(), - It.IsAny())) - .Returns(() => throw new PostgresException("auth failed", "FATAL", "FATAL", "28000")); + SetupSearch(collectionMock).Returns(() => throw new PostgresException("auth failed", "FATAL", "FATAL", "28000")); await Assert.ThrowsAsync(() => svc.ExecuteVectorSearch("test query")); } 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 58f312fe..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; @@ -77,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()); }); } From a0a2e19aba4aa49bdc3d0baec5d58c3c0e684b1c Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Thu, 16 Apr 2026 21:48:34 -0700 Subject: [PATCH 9/9] fix: standardize AppInsights on APPLICATIONINSIGHTS_CONNECTION_STRING Root cause: workflow was setting ApplicationInsights__ConnectionString (double-underscore hierarchical .NET config key) which does NOT match the flat env var that UseAzureMonitor() and Aspire's AzureApplicationInsightsResource both expect. - workflow: rename ApplicationInsights__ConnectionString -> APPLICATIONINSIGHTS_CONNECTION_STRING (both Staging and Production deploy steps; secret reference unchanged) - Program.cs: remove 3-key fallback chain; single builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] check; simplify UseAzureMonitor() to no-arg form (auto-reads the canonical env var) Official docs: 'The Azure Monitor distro expects the environment variable to be APPLICATIONINSIGHTS_CONNECTION_STRING' (learn.microsoft.com/dotnet/aspire) --- .github/workflows/Build-Test-And-Deploy.yml | 4 ++-- EssentialCSharp.Web/Program.cs | 11 ++--------- 2 files changed, 4 insertions(+), 11 deletions(-) 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/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 54fb8669..47044ebd 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -38,14 +38,7 @@ private static void Main(string[] args) // 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. - // Resolve from all config locations: - // 1. Standard Azure Monitor flat env var (SDK's own default key) - // 2. Double-underscore (hierarchical) format set by the deployment workflow - // 3. GetConnectionString fallback - string? appInsightsConnectionString = - builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"] - ?? builder.Configuration["ApplicationInsights:ConnectionString"] - ?? builder.Configuration.GetConnectionString("ApplicationInsights"); + string? appInsightsConnectionString = builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; bool useAzureMonitor = !string.IsNullOrWhiteSpace(appInsightsConnectionString); bool useOtlp = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); @@ -88,7 +81,7 @@ private static void Main(string[] args) }); if (useAzureMonitor) - otel.UseAzureMonitor(options => options.ConnectionString = appInsightsConnectionString); + otel.UseAzureMonitor(); else if (useOtlp) otel.UseOtlpExporter();