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);
+ }
+}