Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@
<PackageVersion Include="FsUnit.xUnit" Version="7.0.1" />
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.0" />
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.16" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class AskAiUsecase(
IStreamTransformer streamTransformer,
ILogger<AskAiUsecase> logger)
{
private static readonly ActivitySource AskAiActivitySource = new("Elastic.Documentation.Api.AskAi");
private static readonly ActivitySource AskAiActivitySource = new(TelemetryConstants.AskAiSourceName);

public async Task<Stream> AskAi(AskAiRequest askAiRequest, Cancel ctx)
{
Expand Down
28 changes: 28 additions & 0 deletions src/api/Elastic.Documentation.Api.Core/TelemetryConstants.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Constants for OpenTelemetry instrumentation in the Docs API.
/// </summary>
public static class TelemetryConstants
{
/// <summary>
/// ActivitySource name for AskAi operations.
/// Used in AskAiUsecase to create spans.
/// </summary>
public const string AskAiSourceName = "Elastic.Documentation.Api.AskAi";

/// <summary>
/// ActivitySource name for StreamTransformer operations.
/// Used in stream transformer implementations to create spans.
/// </summary>
public const string StreamTransformerSourceName = "Elastic.Documentation.Api.StreamTransformer";

/// <summary>
/// Tag/baggage name used to annotate spans with the user's EUID value.
/// </summary>
public const string UserEuidAttributeName = "user.euid";
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Get the agent ID for this transformer
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public class EuidSpanProcessor : BaseProcessor<Activity>
{
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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Middleware that adds the euid cookie value to the logging scope for all subsequent log entries in the request.
/// </summary>
public class EuidLoggingMiddleware(RequestDelegate next, ILogger<EuidLoggingMiddleware> 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<string, object> { [TelemetryConstants.UserEuidAttributeName] = euid }))
{
await next(context);
}
}
else
{
await next(context);
}
}
}

/// <summary>
/// Extension methods for registering the EuidLoggingMiddleware.
/// </summary>
public static class EuidLoggingMiddlewareExtensions
{
/// <summary>
/// Adds the EuidLoggingMiddleware to the application pipeline.
/// This middleware enriches logs with the euid cookie value.
/// </summary>
public static IApplicationBuilder UseEuidLogging(this IApplicationBuilder app) => app.UseMiddleware<EuidLoggingMiddleware>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,6 +15,35 @@ namespace Elastic.Documentation.Api.Infrastructure;

public static class OpenTelemetryExtensions
{
/// <summary>
/// Configures tracing for the Docs API with sources, instrumentation, and enrichment.
/// This is the shared configuration used in both production and tests.
/// </summary>
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<EuidSpanProcessor>() // Automatically add euid to all child spans
.AddHttpClientInstrumentation();

return builder;
}

/// <summary>
/// Configures Elastic OpenTelemetry (EDOT) for the Docs API.
/// Only enables if OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set.
Expand All @@ -31,14 +63,7 @@ public static TBuilder AddDocsApiOpenTelemetry<TBuilder>(
{
_ = edotBuilder
.WithLogging()
.WithTracing(tracing =>
{
_ = tracing
.AddSource("Elastic.Documentation.Api.AskAi")
.AddSource("Elastic.Documentation.Api.StreamTransformer")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
})
.WithTracing(tracing => tracing.AddDocsApiTracing())
.WithMetrics(metrics =>
{
_ = metrics
Expand Down
10 changes: 10 additions & 0 deletions src/api/Elastic.Documentation.Api.Lambda/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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");
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="$(SolutionRoot)\src\api\Elastic.Documentation.Api.Lambda\Elastic.Documentation.Api.Lambda.csproj"/>
<ProjectReference Include="$(SolutionRoot)\src\api\Elastic.Documentation.Api.Infrastructure\Elastic.Documentation.Api.Infrastructure.csproj"/>
<ProjectReference Include="$(SolutionRoot)\src\api\Elastic.Documentation.Api.Core\Elastic.Documentation.Api.Core.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="FakeItEasy" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Integration tests for euid cookie enrichment in OpenTelemetry traces and logging.
/// Uses WebApplicationFactory to test the real API configuration with mocked services.
/// </summary>
public class EuidEnrichmentIntegrationTests(ApiWebApplicationFactory factory) : IClassFixture<ApiWebApplicationFactory>
{
private readonly ApiWebApplicationFactory _factory = factory;

/// <summary>
/// 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.
/// </summary>
[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<string, object> dict &&
dict.TryGetValue(TelemetryConstants.UserEuidAttributeName, out var value) &&
value?.ToString() == expectedEuid);

hasEuidInScope.Should().BeTrue("Log entry should have user.euid in scope from middleware");
}
}
Loading
Loading