diff --git a/Directory.Packages.props b/Directory.Packages.props index 11b537444..25182fc7d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -93,6 +93,8 @@ + + diff --git a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs index 92ef71ef5..4a31ac2e5 100644 --- a/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs +++ b/src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs @@ -13,7 +13,7 @@ public class AskAiUsecase( IStreamTransformer streamTransformer, ILogger logger) { - private static readonly ActivitySource AskAiActivitySource = new("Elastic.Documentation.Api.AskAi"); + private static readonly ActivitySource AskAiActivitySource = new(TelemetryConstants.AskAiSourceName); public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx) { diff --git a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs new file mode 100644 index 000000000..b6a36c7c6 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs @@ -0,0 +1,28 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +namespace Elastic.Documentation.Api.Core; + +/// +/// Constants for OpenTelemetry instrumentation in the Docs API. +/// +public static class TelemetryConstants +{ + /// + /// ActivitySource name for AskAi operations. + /// Used in AskAiUsecase to create spans. + /// + public const string AskAiSourceName = "Elastic.Documentation.Api.AskAi"; + + /// + /// ActivitySource name for StreamTransformer operations. + /// Used in stream transformer implementations to create spans. + /// + public const string StreamTransformerSourceName = "Elastic.Documentation.Api.StreamTransformer"; + + /// + /// Tag/baggage name used to annotate spans with the user's EUID value. + /// + public const string UserEuidAttributeName = "user.euid"; +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs index 1313bf67b..5913b3df2 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs @@ -20,7 +20,7 @@ public abstract class StreamTransformerBase(ILogger logger) : IStreamTransformer protected ILogger Logger { get; } = logger; // ActivitySource for tracing streaming operations - private static readonly ActivitySource StreamTransformerActivitySource = new("Elastic.Documentation.Api.StreamTransformer"); + private static readonly ActivitySource StreamTransformerActivitySource = new(TelemetryConstants.StreamTransformerSourceName); /// /// Get the agent ID for this transformer diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/EuidSpanProcessor.cs b/src/api/Elastic.Documentation.Api.Infrastructure/EuidSpanProcessor.cs new file mode 100644 index 000000000..41f4efd77 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/EuidSpanProcessor.cs @@ -0,0 +1,32 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using Elastic.Documentation.Api.Core; +using OpenTelemetry; + +namespace Elastic.Documentation.Api.Infrastructure; + +/// +/// OpenTelemetry span processor that automatically adds user.euid tag to all spans +/// when it exists in the activity baggage. +/// This ensures the euid is present on all spans (root and children) without manual propagation. +/// +public class EuidSpanProcessor : BaseProcessor +{ + public override void OnStart(Activity activity) + { + // Check if euid exists in baggage (set by ASP.NET Core request enrichment) + var euid = activity.GetBaggageItem(TelemetryConstants.UserEuidAttributeName); + if (!string.IsNullOrEmpty(euid)) + { + // Add as a tag to this span if not already present + var hasEuidTag = activity.TagObjects.Any(t => t.Key == TelemetryConstants.UserEuidAttributeName); + if (!hasEuidTag) + { + _ = activity.SetTag(TelemetryConstants.UserEuidAttributeName, euid); + } + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Middleware/EuidLoggingMiddleware.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Middleware/EuidLoggingMiddleware.cs new file mode 100644 index 000000000..58e384429 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Middleware/EuidLoggingMiddleware.cs @@ -0,0 +1,45 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Elastic.Documentation.Api.Core; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Infrastructure.Middleware; + +/// +/// Middleware that adds the euid cookie value to the logging scope for all subsequent log entries in the request. +/// +public class EuidLoggingMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + // Try to get the euid cookie + if (context.Request.Cookies.TryGetValue("euid", out var euid) && !string.IsNullOrEmpty(euid)) + { + // Add euid to logging scope so it appears in all log entries for this request + using (logger.BeginScope(new Dictionary { [TelemetryConstants.UserEuidAttributeName] = euid })) + { + await next(context); + } + } + else + { + await next(context); + } + } +} + +/// +/// Extension methods for registering the EuidLoggingMiddleware. +/// +public static class EuidLoggingMiddlewareExtensions +{ + /// + /// Adds the EuidLoggingMiddleware to the application pipeline. + /// This middleware enriches logs with the euid cookie value. + /// + public static IApplicationBuilder UseEuidLogging(this IApplicationBuilder app) => app.UseMiddleware(); +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetryExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetryExtensions.cs index 9f424bb44..68f01ebf8 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetryExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetryExtensions.cs @@ -2,7 +2,10 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Diagnostics; +using Elastic.Documentation.Api.Core; using Elastic.OpenTelemetry; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Hosting; using OpenTelemetry; using OpenTelemetry.Metrics; @@ -12,6 +15,35 @@ namespace Elastic.Documentation.Api.Infrastructure; public static class OpenTelemetryExtensions { + /// + /// Configures tracing for the Docs API with sources, instrumentation, and enrichment. + /// This is the shared configuration used in both production and tests. + /// + public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder builder) + { + _ = builder + .AddSource(TelemetryConstants.AskAiSourceName) + .AddSource(TelemetryConstants.StreamTransformerSourceName) + .AddAspNetCoreInstrumentation(aspNetCoreOptions => + { + // Enrich spans with custom attributes from HTTP context + aspNetCoreOptions.EnrichWithHttpRequest = (activity, httpRequest) => + { + // Add euid cookie value to span attributes and baggage + if (httpRequest.Cookies.TryGetValue("euid", out var euid) && !string.IsNullOrEmpty(euid)) + { + _ = activity.SetTag(TelemetryConstants.UserEuidAttributeName, euid); + // Add to baggage so it propagates to all child spans + _ = activity.AddBaggage(TelemetryConstants.UserEuidAttributeName, euid); + } + }; + }) + .AddProcessor() // Automatically add euid to all child spans + .AddHttpClientInstrumentation(); + + return builder; + } + /// /// Configures Elastic OpenTelemetry (EDOT) for the Docs API. /// Only enables if OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set. @@ -31,14 +63,7 @@ public static TBuilder AddDocsApiOpenTelemetry( { _ = edotBuilder .WithLogging() - .WithTracing(tracing => - { - _ = tracing - .AddSource("Elastic.Documentation.Api.AskAi") - .AddSource("Elastic.Documentation.Api.StreamTransformer") - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation(); - }) + .WithTracing(tracing => tracing.AddDocsApiTracing()) .WithMetrics(metrics => { _ = metrics diff --git a/src/api/Elastic.Documentation.Api.Lambda/Program.cs b/src/api/Elastic.Documentation.Api.Lambda/Program.cs index a9f51dbda..bb03c7e7c 100644 --- a/src/api/Elastic.Documentation.Api.Lambda/Program.cs +++ b/src/api/Elastic.Documentation.Api.Lambda/Program.cs @@ -8,6 +8,7 @@ using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Core.Search; using Elastic.Documentation.Api.Infrastructure; +using Elastic.Documentation.Api.Infrastructure.Middleware; try { @@ -37,6 +38,10 @@ builder.Services.AddElasticDocsApiUsecases(environment); var app = builder.Build(); + + // Add middleware to enrich logs with euid cookie + _ = app.UseEuidLogging(); + var v1 = app.MapGroup("/docs/_api/v1"); v1.MapElasticDocsApiEndpoints(); Console.WriteLine("API endpoints mapped"); @@ -58,3 +63,8 @@ [JsonSerializable(typeof(SearchRequest))] [JsonSerializable(typeof(SearchResponse))] internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext; + +// Make the Program class accessible for integration testing +#pragma warning disable ASP0027 +public partial class Program { } +#pragma warning restore ASP0027 diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj new file mode 100644 index 000000000..32e364eba --- /dev/null +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + + + + + + + + + + + + + + + diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs new file mode 100644 index 000000000..1d6b6e757 --- /dev/null +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs @@ -0,0 +1,85 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using System.Text; +using Elastic.Documentation.Api.Core; +using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using FluentAssertions; + +namespace Elastic.Documentation.Api.IntegrationTests; + +/// +/// Integration tests for euid cookie enrichment in OpenTelemetry traces and logging. +/// Uses WebApplicationFactory to test the real API configuration with mocked services. +/// +public class EuidEnrichmentIntegrationTests(ApiWebApplicationFactory factory) : IClassFixture +{ + private readonly ApiWebApplicationFactory _factory = factory; + + /// + /// Test that verifies euid cookie is added to both HTTP span and custom AskAi span, + /// and appears in log entries - using the real API configuration. + /// + [Fact] + public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() + { + // Arrange + const string expectedEuid = "integration-test-euid-12345"; + + // Create client + using var client = _factory.CreateClient(); + + // Act - Make request to /ask-ai/stream with euid cookie + using var request = new HttpRequestMessage(HttpMethod.Post, "/docs/_api/v1/ask-ai/stream"); + request.Headers.Add("Cookie", $"euid={expectedEuid}"); + request.Content = new StringContent( + """{"message":"test question","conversationId":null}""", + Encoding.UTF8, + "application/json" + ); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + // Assert - Response is successful + response.IsSuccessStatusCode.Should().BeTrue(); + + // Assert - Verify spans were captured + var activities = _factory.ExportedActivities; + activities.Should().NotBeEmpty("OpenTelemetry should have captured activities"); + + // Verify HTTP span has euid + var httpSpan = activities.FirstOrDefault(a => + a.DisplayName.Contains("POST") && a.DisplayName.Contains("ask-ai")); + httpSpan.Should().NotBeNull("Should have captured HTTP request span"); + var httpEuidTag = httpSpan!.TagObjects.FirstOrDefault(t => t.Key == TelemetryConstants.UserEuidAttributeName); + httpEuidTag.Should().NotBeNull("HTTP span should have user.euid tag"); + httpEuidTag.Value.Should().Be(expectedEuid, "HTTP span euid should match cookie value"); + + // Verify custom AskAi span has euid (proves baggage + processor work) + var askAiSpan = activities.FirstOrDefault(a => a.Source.Name == TelemetryConstants.AskAiSourceName); + askAiSpan.Should().NotBeNull("Should have captured custom AskAi span from AskAiUsecase"); + var askAiEuidTag = askAiSpan!.TagObjects.FirstOrDefault(t => t.Key == TelemetryConstants.UserEuidAttributeName); + askAiEuidTag.Should().NotBeNull("AskAi span should have user.euid tag from baggage"); + askAiEuidTag.Value.Should().Be(expectedEuid, "AskAi span euid should match cookie value"); + + // Assert - Verify logs have euid in scope + var logEntries = _factory.LogEntries; + logEntries.Should().NotBeEmpty("Should have captured log entries"); + + // Find a log entry from AskAiUsecase + var askAiLog = logEntries.FirstOrDefault(e => + e.CategoryName.Contains("AskAiUsecase") && + e.Message.Contains("Starting AskAI")); + askAiLog.Should().NotBeNull("Should have logged from AskAiUsecase"); + + // Verify euid is in the logging scope + var hasEuidInScope = askAiLog!.Scopes + .Any(scope => scope is IDictionary dict && + dict.TryGetValue(TelemetryConstants.UserEuidAttributeName, out var value) && + value?.ToString() == expectedEuid); + + hasEuidInScope.Should().BeTrue("Log entry should have user.euid in scope from middleware"); + } +} diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs new file mode 100644 index 000000000..8c9f916aa --- /dev/null +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs @@ -0,0 +1,145 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Diagnostics; +using System.Text; +using Elastic.Documentation.Api.Core.AskAi; +using Elastic.Documentation.Api.Infrastructure; +using Elastic.Documentation.Api.Infrastructure.Aws; +using FakeItEasy; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Trace; + +namespace Elastic.Documentation.Api.IntegrationTests.Fixtures; + +/// +/// Custom WebApplicationFactory for testing the API with mocked services. +/// This fixture can be reused across multiple test classes. +/// +public class ApiWebApplicationFactory : WebApplicationFactory +{ + public List ExportedActivities { get; } = []; + public List LogEntries { get; } = []; + private readonly List MockMemoryStreams = new(); + protected override void ConfigureWebHost(IWebHostBuilder builder) => + builder.ConfigureServices(services => + { + // Configure OpenTelemetry with in-memory exporter for testing + // Uses the same production configuration via AddDocsApiTracing() + _ = services.AddOpenTelemetry() + .WithTracing(tracing => + { + _ = tracing + .AddDocsApiTracing() // Reuses production configuration + .AddInMemoryExporter(ExportedActivities); + }); + + // Configure logging to capture log entries + _ = services.AddLogging(logging => + { + _ = logging.AddProvider(new TestLoggerProvider(LogEntries)); + }); + + // Mock IParameterProvider to avoid AWS dependencies + var mockParameterProvider = A.Fake(); + A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) + .Returns(Task.FromResult("mock-value")); + _ = services.AddSingleton(mockParameterProvider); + + // Mock IAskAiGateway to avoid external AI service calls + var mockAskAiGateway = A.Fake>(); + A.CallTo(() => mockAskAiGateway.AskAi(A._, A._)) + .ReturnsLazily(() => { + var stream = new MemoryStream(Encoding.UTF8.GetBytes("data: test\n\n")); + MockMemoryStreams.Add(stream); + return Task.FromResult(stream); + }); + _ = services.AddSingleton(mockAskAiGateway); + + // Mock IStreamTransformer + var mockTransformer = A.Fake(); + A.CallTo(() => mockTransformer.AgentProvider).Returns("test-provider"); + A.CallTo(() => mockTransformer.AgentId).Returns("test-agent"); + A.CallTo(() => mockTransformer.TransformAsync(A._, A._, A._, A._)) + .ReturnsLazily((Stream s, string? _, Activity? activity, Cancel _) => + { + // Dispose the activity if provided (simulating what the real transformer does) + activity?.Dispose(); + return Task.FromResult(s); + }); + _ = services.AddSingleton(mockTransformer); + }); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var stream in MockMemoryStreams) + { + stream.Dispose(); + } + MockMemoryStreams.Clear(); + } + base.Dispose(disposing); + } +} + +/// +/// Test logger provider for capturing log entries with scopes. +/// +internal sealed class TestLoggerProvider(List logEntries) : ILoggerProvider +{ + private readonly List _sharedScopes = []; + + public ILogger CreateLogger(string categoryName) => new TestLogger(categoryName, logEntries, _sharedScopes); + public void Dispose() { } +} + +/// +/// Test logger that captures log entries with their scopes. +/// +internal sealed class TestLogger(string categoryName, List logEntries, List sharedScopes) : ILogger +{ + public IDisposable BeginScope(TState state) where TState : notnull + { + sharedScopes.Add(state); + return new ScopeDisposable(sharedScopes, state); + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + var entry = new TestLogEntry + { + CategoryName = categoryName, + LogLevel = logLevel, + Message = formatter(state, exception), + Exception = exception, + Scopes = [.. sharedScopes] + }; + logEntries.Add(entry); + } + + private sealed class ScopeDisposable(List scopes, object state) : IDisposable + { + public void Dispose() => scopes.Remove(state); + } +} + +/// +/// Represents a captured log entry with its scopes. +/// +public sealed class TestLogEntry +{ + public required string CategoryName { get; init; } + public LogLevel LogLevel { get; init; } + public required string Message { get; init; } + public Exception? Exception { get; init; } + public List Scopes { get; init; } = []; +} diff --git a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj index c39a65021..6513b2c7d 100644 --- a/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj +++ b/tests/Elastic.Documentation.Api.Infrastructure.Tests/Elastic.Documentation.Api.Infrastructure.Tests.csproj @@ -9,4 +9,10 @@ + + + + + +