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