diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e69507a4..05320f0cd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,7 @@ + @@ -41,6 +42,7 @@ + @@ -106,4 +108,4 @@ - \ No newline at end of file + diff --git a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj index e6e497c1b..366861512 100644 --- a/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj +++ b/src/api/Elastic.Documentation.Api.Core/Elastic.Documentation.Api.Core.csproj @@ -9,8 +9,10 @@ + + diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs new file mode 100644 index 000000000..ae2518af2 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/IOtlpGateway.cs @@ -0,0 +1,25 @@ +// 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.Telemetry; + +/// +/// Gateway for forwarding OTLP telemetry to a collector. +/// +public interface IOtlpGateway +{ + /// + /// Forwards OTLP telemetry data to the collector. + /// + /// The OTLP signal type (traces, logs, or metrics) + /// The raw OTLP payload stream + /// Content-Type of the payload + /// Cancellation token + /// HTTP status code and response content + Task<(int StatusCode, string? Content)> ForwardOtlp( + OtlpSignalType signalType, + Stream requestBody, + string contentType, + Cancel ctx = default); +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs new file mode 100644 index 000000000..4872cb9b8 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyOptions.cs @@ -0,0 +1,48 @@ +// 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 Microsoft.Extensions.Configuration; + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Configuration options for the OTLP proxy. +/// The proxy forwards telemetry to a local OTLP collector (typically ADOT Lambda Layer). +/// +/// +/// ADOT Lambda Layer runs a local OpenTelemetry Collector that accepts OTLP/HTTP on: +/// - localhost:4318 (HTTP/JSON and HTTP/protobuf) +/// - localhost:4317 (gRPC) +/// +/// Configuration priority: +/// 1. OtlpProxy:Endpoint in IConfiguration (for tests/overrides) +/// 2. OTEL_EXPORTER_OTLP_ENDPOINT environment variable +/// 3. Default: http://localhost:4318 +/// +/// The proxy will return 503 if the collector is not available. +/// +public class OtlpProxyOptions +{ + /// + /// OTLP endpoint URL for the local ADOT collector. + /// Defaults to localhost:4318 when running in Lambda with ADOT layer. + /// + public string Endpoint { get; } + + public OtlpProxyOptions(IConfiguration configuration) + { + // Check for explicit configuration override first (for tests or custom deployments) + var configEndpoint = configuration["OtlpProxy:Endpoint"]; + if (!string.IsNullOrEmpty(configEndpoint)) + { + Endpoint = configEndpoint; + return; + } + + // Default to localhost:4318 - this is where ADOT Lambda Layer collector runs + // If ADOT layer is not present, the proxy will fail gracefully and return 503 + Endpoint = Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") + ?? "http://localhost:4318"; + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs new file mode 100644 index 000000000..6b79762cc --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyRequest.cs @@ -0,0 +1,46 @@ +// 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.ComponentModel.DataAnnotations; +using NetEscapades.EnumGenerators; + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// OTLP signal types supported by the proxy. +/// The Display names match the OTLP path segments (lowercase). +/// +[EnumExtensions] +public enum OtlpSignalType +{ + /// + /// Distributed traces - maps to /v1/traces + /// + [Display(Name = "traces")] + Traces, + + /// + /// Log records - maps to /v1/logs + /// + [Display(Name = "logs")] + Logs, + + /// + /// Metrics data - maps to /v1/metrics + /// + [Display(Name = "metrics")] + Metrics +} + +/// +/// Request model for OTLP proxy endpoint. +/// Accepts raw OTLP payload from frontend and forwards to configured OTLP endpoint. +/// +public class OtlpProxyRequest +{ + /// + /// The OTLP signal type: traces, logs, or metrics + /// + public required string SignalType { get; init; } +} diff --git a/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs new file mode 100644 index 000000000..a0eb1fb9f --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Core/Telemetry/OtlpProxyUsecase.cs @@ -0,0 +1,36 @@ +// 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; + +namespace Elastic.Documentation.Api.Core.Telemetry; + +/// +/// Proxies OTLP telemetry from the frontend to the local ADOT Lambda Layer collector. +/// The ADOT layer handles authentication and forwarding to the backend. +/// +public class OtlpProxyUsecase(IOtlpGateway gateway) +{ + private static readonly ActivitySource ActivitySource = new(TelemetryConstants.OtlpProxySourceName); + + /// + /// Proxies OTLP data from the frontend to the local ADOT collector. + /// + /// The OTLP signal type (traces, logs, or metrics) + /// The raw OTLP payload (JSON or protobuf) + /// Content-Type header from the original request + /// Cancellation token + /// HTTP status code and response content + public async Task<(int StatusCode, string? Content)> ProxyOtlp( + OtlpSignalType signalType, + Stream requestBody, + string contentType, + Cancel ctx = default) + { + using var activity = ActivitySource.StartActivity("ProxyOtlp", ActivityKind.Client); + + // Forward to gateway + return await gateway.ForwardOtlp(signalType, requestBody, contentType, ctx); + } +} diff --git a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs index b6a36c7c6..6a8d2683c 100644 --- a/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs +++ b/src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs @@ -25,4 +25,10 @@ public static class TelemetryConstants /// Tag/baggage name used to annotate spans with the user's EUID value. /// public const string UserEuidAttributeName = "user.euid"; + + /// + /// ActivitySource name for OTLP proxy operations. + /// Used to trace frontend telemetry proxying. + /// + public const string OtlpProxySourceName = "Elastic.Documentation.Api.OtlpProxy"; } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs index cdf53b3c9..6748a40b1 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/AgentBuilderAskAiGateway.cs @@ -37,7 +37,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) var kibanaUrl = await parameterProvider.GetParam("docs-kibana-url", false, ctx); var kibanaApiKey = await parameterProvider.GetParam("docs-kibana-apikey", true, ctx); - var request = new HttpRequestMessage(HttpMethod.Post, + using var request = new HttpRequestMessage(HttpMethod.Post, $"{kibanaUrl}/api/agent_builder/converse/async") { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") @@ -45,7 +45,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) request.Headers.Add("kbn-xsrf", "true"); request.Headers.Authorization = new AuthenticationHeaderValue("ApiKey", kibanaApiKey); - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); // Ensure the response is successful before streaming if (!response.IsSuccessStatusCode) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs index f7d1cdf70..64e3c72ca 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/LlmGatewayAskAiGateway.cs @@ -25,7 +25,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) { var llmGatewayRequest = LlmGatewayRequest.CreateFromRequest(askAiRequest); var requestBody = JsonSerializer.Serialize(llmGatewayRequest, LlmGatewayContext.Default.LlmGatewayRequest); - var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl) + using var request = new HttpRequestMessage(HttpMethod.Post, options.FunctionUrl) { Content = new StringContent(requestBody, Encoding.UTF8, "application/json") }; @@ -37,7 +37,7 @@ public async Task AskAi(AskAiRequest askAiRequest, Cancel ctx = default) // Use HttpCompletionOption.ResponseHeadersRead to get headers immediately // This allows us to start streaming as soon as headers are received - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ctx); // Ensure the response is successful before streaming if (!response.IsSuccessStatusCode) diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs new file mode 100644 index 000000000..600af78e5 --- /dev/null +++ b/src/api/Elastic.Documentation.Api.Infrastructure/Adapters/Telemetry/AdotOtlpGateway.cs @@ -0,0 +1,74 @@ +// 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.Telemetry; +using Microsoft.Extensions.Logging; + +namespace Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; + +/// +/// Gateway that forwards OTLP telemetry to the ADOT Lambda Layer collector. +/// +public class AdotOtlpGateway( + IHttpClientFactory httpClientFactory, + OtlpProxyOptions options, + ILogger logger) : IOtlpGateway +{ + public const string HttpClientName = "OtlpProxy"; + private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); + + /// + public async Task<(int StatusCode, string? Content)> ForwardOtlp( + OtlpSignalType signalType, + Stream requestBody, + string contentType, + Cancel ctx = default) + { + try + { + // Build the target URL: http://localhost:4318/v1/{signalType} + // Use ToStringFast(true) from generated enum extensions (returns Display name: "traces", "logs", "metrics") + var targetUrl = $"{options.Endpoint.TrimEnd('/')}/v1/{signalType.ToStringFast(true)}"; + + logger.LogDebug("Forwarding OTLP {SignalType} to ADOT collector at {TargetUrl}", signalType, targetUrl); + + using var request = new HttpRequestMessage(HttpMethod.Post, targetUrl); + + // Forward the content with the original content type + request.Content = new StreamContent(requestBody); + _ = request.Content.Headers.TryAddWithoutValidation("Content-Type", contentType); + + // No need to add authentication headers - ADOT layer handles auth to backend + // Just forward the telemetry to the local collector + + // Forward to ADOT collector + using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, ctx); + var responseContent = response.Content.Headers.ContentLength > 0 + ? await response.Content.ReadAsStringAsync(ctx) + : string.Empty; + + if (!response.IsSuccessStatusCode) + { + logger.LogError("OTLP forward to ADOT failed with status {StatusCode}: {Content}", + response.StatusCode, responseContent); + } + else + { + logger.LogDebug("Successfully forwarded OTLP {SignalType} to ADOT collector", signalType); + } + + return ((int)response.StatusCode, responseContent); + } + catch (HttpRequestException ex) when (ex.Message.Contains("Connection refused") || ex.InnerException?.Message?.Contains("Connection refused") == true) + { + logger.LogError(ex, "Failed to connect to ADOT collector at {Endpoint}. Is ADOT Lambda Layer enabled?", options.Endpoint); + return (503, "ADOT collector not available. Ensure AWS_LAMBDA_EXEC_WRAPPER=/opt/otel-instrument is set"); + } + catch (Exception ex) + { + logger.LogError(ex, "Error forwarding OTLP {SignalType}", signalType); + return (500, $"Error forwarding OTLP: {ex.Message}"); + } + } +} diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs index c95372a69..1c605e4a4 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/MappingsExtensions.cs @@ -4,6 +4,7 @@ using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Core.Search; +using Elastic.Documentation.Api.Core.Telemetry; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -19,6 +20,7 @@ public static void MapElasticDocsApiEndpoints(this IEndpointRouteBuilder group) _ = group.MapPost("/", () => Results.Empty); MapAskAiEndpoint(group); MapSearchEndpoint(group); + MapOtlpProxyEndpoint(group); } private static void MapAskAiEndpoint(IEndpointRouteBuilder group) @@ -55,4 +57,43 @@ Cancel ctx return Results.Ok(searchResponse); }); } + + private static void MapOtlpProxyEndpoint(IEndpointRouteBuilder group) + { + // Use /o/* to avoid adblocker detection (common blocklists target /otlp, /telemetry, etc.) + var otlpGroup = group.MapGroup("/o"); + + // Proxy endpoint for traces + // Frontend: POST /_api/v1/o/t → ADOT: POST localhost:4318/v1/traces + _ = otlpGroup.MapPost("/t", + async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => + { + var contentType = context.Request.ContentType ?? "application/json"; + var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Traces, context.Request.Body, contentType, ctx); + return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + }) + .DisableAntiforgery(); // Frontend requests won't have antiforgery tokens + + // Proxy endpoint for logs + // Frontend: POST /_api/v1/o/l → ADOT: POST localhost:4318/v1/logs + _ = otlpGroup.MapPost("/l", + async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => + { + var contentType = context.Request.ContentType ?? "application/json"; + var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Logs, context.Request.Body, contentType, ctx); + return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + }) + .DisableAntiforgery(); + + // Proxy endpoint for metrics + // Frontend: POST /_api/v1/o/m → ADOT: POST localhost:4318/v1/metrics + _ = otlpGroup.MapPost("/m", + async (HttpContext context, OtlpProxyUsecase proxyUsecase, Cancel ctx) => + { + var contentType = context.Request.ContentType ?? "application/json"; + var (statusCode, content) = await proxyUsecase.ProxyOtlp(OtlpSignalType.Metrics, context.Request.Body, contentType, ctx); + return Results.Content(content ?? string.Empty, contentType, statusCode: statusCode); + }) + .DisableAntiforgery(); + } } diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs index 7927ab1a7..55ad64151 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs @@ -36,6 +36,7 @@ public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder _ = builder .AddSource(TelemetryConstants.AskAiSourceName) .AddSource(TelemetryConstants.StreamTransformerSourceName) + .AddSource(TelemetryConstants.OtlpProxySourceName) .AddAspNetCoreInstrumentation(aspNetCoreOptions => { // Enrich spans with custom attributes from HTTP context diff --git a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs index 328bff119..84c761922 100644 --- a/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs +++ b/src/api/Elastic.Documentation.Api.Infrastructure/ServicesExtension.cs @@ -6,10 +6,13 @@ using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.Core.Search; +using Elastic.Documentation.Api.Core.Telemetry; using Elastic.Documentation.Api.Infrastructure.Adapters.AskAi; using Elastic.Documentation.Api.Infrastructure.Adapters.Search; +using Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; using Elastic.Documentation.Api.Infrastructure.Aws; using Elastic.Documentation.Api.Infrastructure.Gcp; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NetEscapades.EnumGenerators; @@ -71,6 +74,7 @@ private static void AddElasticDocsApiUsecases(this IServiceCollection services, AddParameterProvider(services, appEnv); AddAskAiUsecase(services, appEnv); AddSearchUsecase(services, appEnv); + AddOtlpProxyUsecase(services, appEnv); } // https://docs.aws.amazon.com/systems -manager/latest/userguide/ps-integration-lambda-extensions.html @@ -171,4 +175,27 @@ private static void AddSearchUsecase(IServiceCollection services, AppEnv appEnv) _ = services.AddScoped(); _ = services.AddScoped(); } + + private static void AddOtlpProxyUsecase(IServiceCollection services, AppEnv appEnv) + { + var logger = GetLogger(services); + logger?.LogInformation("Configuring OTLP proxy use case for environment {AppEnvironment}", appEnv); + + _ = services.AddSingleton(sp => + { + var config = sp.GetRequiredService(); + return new OtlpProxyOptions(config); + }); + + // Register named HttpClient for OTLP proxy + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigureHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(30); + }); + + _ = services.AddScoped(); + _ = services.AddScoped(); + logger?.LogInformation("OTLP proxy configured to forward to ADOT Lambda Layer collector"); + } } 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 index 32e364eba..d319a74c5 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Elastic.Documentation.Api.IntegrationTests.csproj @@ -14,6 +14,7 @@ + diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs index 3920b3296..f2f4e1425 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs @@ -7,18 +7,18 @@ using Elastic.Documentation.Api.Core; using Elastic.Documentation.Api.Core.AskAi; using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using FakeItEasy; using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; 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. +/// Uses WebApplicationFactory to test the real API configuration with mocked AskAi services. /// -public class EuidEnrichmentIntegrationTests(ApiWebApplicationFactory factory) : IClassFixture +public class EuidEnrichmentIntegrationTests { - 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. @@ -29,8 +29,39 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() // Arrange const string expectedEuid = "integration-test-euid-12345"; + // Track streams created by mocks so we can dispose them after the test + var mockStreams = new List(); + + // Create factory with mocked AskAi services + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + { + // 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")); + mockStreams.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); + }); + // Create client - using var client = _factory.CreateClient(); + 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"); @@ -48,7 +79,7 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() response.IsSuccessStatusCode.Should().BeTrue(); // Assert - Verify spans were captured - var activities = _factory.ExportedActivities; + var activities = factory.ExportedActivities; activities.Should().NotBeEmpty("OpenTelemetry should have captured activities"); // Verify HTTP span has euid @@ -67,7 +98,7 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() askAiEuidTag.Value.Should().Be(expectedEuid, "AskAi span euid should match cookie value"); // Assert - Verify logs have euid in attributes - var logRecords = _factory.ExportedLogRecords; + var logRecords = factory.ExportedLogRecords; logRecords.Should().NotBeEmpty("Should have captured log records"); // Find a log entry from AskAiUsecase @@ -80,5 +111,9 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs() var euidAttribute = askAiLogRecord!.Attributes?.FirstOrDefault(a => a.Key == TelemetryConstants.UserEuidAttributeName) ?? default; euidAttribute.Should().NotBe(default(KeyValuePair), "Log record should include user.euid attribute"); (euidAttribute.Value?.ToString() ?? string.Empty).Should().Be(expectedEuid, "Log record euid should match cookie value"); + + // Cleanup - dispose all mock streams + foreach (var stream in mockStreams) + stream.Dispose(); } } diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs index 96c60e7aa..f6483b21e 100644 --- a/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs @@ -3,8 +3,6 @@ // 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 Elastic.Documentation.Api.Infrastructure.OpenTelemetry; @@ -12,7 +10,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection.Extensions; using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Trace; @@ -22,70 +20,134 @@ namespace Elastic.Documentation.Api.IntegrationTests.Fixtures; /// /// Custom WebApplicationFactory for testing the API with mocked services. /// This fixture can be reused across multiple test classes. +/// Only mocks services that ALL tests need (OpenTelemetry, AWS Parameters). +/// Test-specific mocks should be configured using WithMockedServices. /// public class ApiWebApplicationFactory : WebApplicationFactory { public List ExportedActivities { get; } = []; public List ExportedLogRecords { get; } = []; - private readonly List _mockMemoryStreams = []; - protected override void ConfigureWebHost(IWebHostBuilder builder) => - builder.ConfigureServices(services => + private readonly Action? _configureServices; + + public ApiWebApplicationFactory() : this(null) + { + } + + internal ApiWebApplicationFactory(Action? configureServices) => _configureServices = configureServices; + + /// + /// Creates a factory with specific services replaced by mocks. + /// This allows tests to inject fake implementations for testing specific scenarios. + /// + /// Action to configure service replacements + /// New factory instance with replaced services + public static ApiWebApplicationFactory WithMockedServices(Action serviceReplacements) + { + var builder = new ServiceReplacementBuilder(); + serviceReplacements(builder); + return new ApiWebApplicationFactory(builder.Build()); + } + + /// + /// Creates a factory with custom service configuration. + /// + /// Action to configure services directly + /// New factory instance with custom service configuration + public static ApiWebApplicationFactory WithMockedServices(Action configureServices) + => new(configureServices); + + protected override void ConfigureWebHost(IWebHostBuilder builder) => builder.ConfigureServices(services => + { + // Configure OpenTelemetry with in-memory exporters for all tests + var otelBuilder = services.AddOpenTelemetry(); + _ = otelBuilder.WithTracing(tracing => + { + _ = tracing + .AddDocsApiTracing() // Reuses production configuration + .AddInMemoryExporter(ExportedActivities); + }); + _ = otelBuilder.WithLogging(logging => { - var otelBuilder = services.AddOpenTelemetry(); - _ = otelBuilder.WithTracing(tracing => - { - _ = tracing - .AddDocsApiTracing() // Reuses production configuration - .AddInMemoryExporter(ExportedActivities); - }); - _ = otelBuilder.WithLogging(logging => - { - _ = logging - .AddDocsApiLogging() // Reuses production configuration - .AddInMemoryExporter(ExportedLogRecords); - }); + _ = logging + .AddDocsApiLogging() // Reuses production configuration + .AddInMemoryExporter(ExportedLogRecords); + }); + + // Mock IParameterProvider to avoid AWS dependencies in all tests + var mockParameterProvider = A.Fake(); + A.CallTo(() => mockParameterProvider.GetParam(A._, A._, A._)) + .Returns(Task.FromResult("mock-value")); + _ = services.AddSingleton(mockParameterProvider); + + // Apply test-specific service replacements (if any) + _configureServices?.Invoke(services); + }); +} - // 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); +/// +/// Builder for replacing services in integration tests. +/// Provides a fluent API for replacing multiple services with mocks. +/// +public class ServiceReplacementBuilder +{ + private readonly List> _replacements = []; + + /// + /// Replace a service of type TService with a specific instance. + /// + /// The service interface type to replace + /// The mock/fake instance to use + /// This builder for chaining + public ServiceReplacementBuilder Replace(TService instance) where TService : class + { + _replacements.Add(services => + { + services.RemoveAll(); + _ = services.AddScoped(_ => instance); + }); + return this; + } - // 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); + /// + /// Replace a service of type TService with a factory function. + /// + /// The service interface type to replace + /// Factory function to create the service + /// This builder for chaining + public ServiceReplacementBuilder Replace(Func factory) where TService : class + { + _replacements.Add(services => + { + services.RemoveAll(); + _ = services.AddScoped(factory); + }); + return this; + } - // 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); + /// + /// Replace a service with a singleton instance. + /// + /// The service interface type to replace + /// The singleton instance to use + /// This builder for chaining + public ServiceReplacementBuilder ReplaceSingleton(TService instance) where TService : class + { + _replacements.Add(services => + { + services.RemoveAll(); + _ = services.AddSingleton(_ => instance); }); + return this; + } - protected override void Dispose(bool disposing) + /// + /// Builds the final service configuration action. + /// + internal Action Build() => services => { - if (disposing) + foreach (var replacement in _replacements) { - foreach (var stream in _mockMemoryStreams) - { - stream.Dispose(); - } - _mockMemoryStreams.Clear(); + replacement(services); } - base.Dispose(disposing); - } + }; } diff --git a/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs new file mode 100644 index 000000000..bcecdab1d --- /dev/null +++ b/tests-integration/Elastic.Documentation.Api.IntegrationTests/OtlpProxyIntegrationTests.cs @@ -0,0 +1,245 @@ +// 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.Net; +using System.Text; +using Elastic.Documentation.Api.Infrastructure.Adapters.Telemetry; +using Elastic.Documentation.Api.IntegrationTests.Fixtures; +using FakeItEasy; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Elastic.Documentation.Api.IntegrationTests; + +public class OtlpProxyIntegrationTests +{ + [Fact] + public async Task OtlpProxyTracesEndpointForwardsToCorrectUrl() + { + // Arrange + var mockHandler = A.Fake(); + var capturedRequest = (HttpRequestMessage?)null; + + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) + .Returns(Task.FromResult(mockResponse)); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + { + // Replace the named HttpClient with our mock + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + + var client = factory.CreateClient(); + var otlpPayload = /*lang=json,strict*/ """ + { + "resourceSpans": [{ + "scopeSpans": [{ + "spans": [{ + "traceId": "0123456789abcdef0123456789abcdef", + "spanId": "0123456789abcdef", + "name": "test-span" + }] + }] + }] + } + """; + + using var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + // Act + using var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); + + // Assert - verify the request was forwarded to the correct URL + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + throw new Exception($"Test failed with {response.StatusCode}: {errorBody}"); + } + + response.StatusCode.Should().Be(HttpStatusCode.OK); + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri.Should().NotBeNull(); + capturedRequest.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/traces"); + capturedRequest.Method.Should().Be(HttpMethod.Post); + capturedRequest.Content.Should().NotBeNull(); + capturedRequest.Content!.Headers.ContentType!.MediaType.Should().Be("application/json"); + + // Cleanup mock response + mockResponse.Dispose(); + } + + [Fact] + public async Task OtlpProxyLogsEndpointForwardsToCorrectUrl() + { + // Arrange + var mockHandler = A.Fake(); + var capturedRequest = (HttpRequestMessage?)null; + + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) + .Returns(Task.FromResult(mockResponse)); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + { + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + + var client = factory.CreateClient(); + var otlpPayload = /*lang=json,strict*/ """ + { + "resourceLogs": [{ + "scopeLogs": [{ + "logRecords": [{ + "timeUnixNano": "1672531200000000000", + "severityNumber": 9, + "severityText": "INFO", + "body": { + "stringValue": "Test log" + } + }] + }] + }] + } + """; + + using var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + // Act + using var response = await client.PostAsync("/docs/_api/v1/o/l", content, TestContext.Current.CancellationToken); + + // Assert - verify the enum ToStringFast() generates "logs" (lowercase) + response.StatusCode.Should().Be(HttpStatusCode.OK); + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/logs"); + + // Cleanup mock response + mockResponse.Dispose(); + } + + [Fact] + public async Task OtlpProxyMetricsEndpointForwardsToCorrectUrl() + { + // Arrange + var mockHandler = A.Fake(); + var capturedRequest = (HttpRequestMessage?)null; + + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}") + }; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Invokes((HttpRequestMessage req, CancellationToken ct) => capturedRequest = req) + .Returns(Task.FromResult(mockResponse)); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + { + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + + var client = factory.CreateClient(); + var otlpPayload = /*lang=json,strict*/ """ + { + "resourceMetrics": [{ + "scopeMetrics": [{ + "metrics": [{ + "name": "test_metric", + "unit": "1" + }] + }] + }] + } + """; + + using var content = new StringContent(otlpPayload, Encoding.UTF8, "application/json"); + + // Act + using var response = await client.PostAsync("/docs/_api/v1/o/m", content, TestContext.Current.CancellationToken); + + // Assert - verify the enum ToStringFast() generates "metrics" (lowercase) + response.StatusCode.Should().Be(HttpStatusCode.OK); + capturedRequest.Should().NotBeNull(); + capturedRequest!.RequestUri!.ToString().Should().Be("http://localhost:4318/v1/metrics"); + + // Cleanup mock response + mockResponse.Dispose(); + } + + [Fact] + public async Task OtlpProxyReturnsCollectorErrorStatusCode() + { + // Arrange + var mockHandler = A.Fake(); + + // Create mock response (will be disposed by HttpClient) + var mockResponse = new HttpResponseMessage(HttpStatusCode.ServiceUnavailable) + { + Content = new StringContent("Service unavailable") + }; + + A.CallTo(mockHandler) + .Where(call => call.Method.Name == "SendAsync") + .WithReturnType>() + .Returns(Task.FromResult(mockResponse)); + + using var factory = ApiWebApplicationFactory.WithMockedServices(services => + { + _ = services.AddHttpClient(AdotOtlpGateway.HttpClientName) + .ConfigurePrimaryHttpMessageHandler(() => mockHandler); + }); + + var client = factory.CreateClient(); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Act + using var response = await client.PostAsync("/docs/_api/v1/o/t", content, TestContext.Current.CancellationToken); + + // Assert - verify error responses are properly forwarded + response.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); + var responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + responseBody.Should().Contain("Service unavailable"); + + // Cleanup mock response + mockResponse.Dispose(); + } + + [Fact] + public async Task OtlpProxyInvalidSignalTypeReturns404() + { + // Arrange + using var factory = new ApiWebApplicationFactory(); + using var client = factory.CreateClient(); + using var content = new StringContent("{}", Encoding.UTF8, "application/json"); + + // Act - use invalid signal type + using var response = await client.PostAsync("/docs/_api/v1/o/invalid", content, TestContext.Current.CancellationToken); + + // Assert - route doesn't exist + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +}