Skip to content

Commit 43d4814

Browse files
Add euid cookie value to logs and traces (#2237)
* Add euid cookie value to logs and traces * Handle CodeQL suggestions * Revert ElasticsearchGateway.cs * Potential fix for pull request finding 'Missing Dispose call on local IDisposable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent fd139be commit 43d4814

File tree

12 files changed

+407
-10
lines changed

12 files changed

+407
-10
lines changed

Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@
9393
<PackageVersion Include="FsUnit.xUnit" Version="7.0.1" />
9494
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
9595
<PackageVersion Include="JetBrains.Annotations" Version="2024.3.0" />
96+
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
97+
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
9698
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
9799
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.0" />
98100
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.16" />

src/api/Elastic.Documentation.Api.Core/AskAi/AskAiUsecase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public class AskAiUsecase(
1313
IStreamTransformer streamTransformer,
1414
ILogger<AskAiUsecase> logger)
1515
{
16-
private static readonly ActivitySource AskAiActivitySource = new("Elastic.Documentation.Api.AskAi");
16+
private static readonly ActivitySource AskAiActivitySource = new(TelemetryConstants.AskAiSourceName);
1717

1818
public async Task<Stream> AskAi(AskAiRequest askAiRequest, Cancel ctx)
1919
{
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
namespace Elastic.Documentation.Api.Core;
6+
7+
/// <summary>
8+
/// Constants for OpenTelemetry instrumentation in the Docs API.
9+
/// </summary>
10+
public static class TelemetryConstants
11+
{
12+
/// <summary>
13+
/// ActivitySource name for AskAi operations.
14+
/// Used in AskAiUsecase to create spans.
15+
/// </summary>
16+
public const string AskAiSourceName = "Elastic.Documentation.Api.AskAi";
17+
18+
/// <summary>
19+
/// ActivitySource name for StreamTransformer operations.
20+
/// Used in stream transformer implementations to create spans.
21+
/// </summary>
22+
public const string StreamTransformerSourceName = "Elastic.Documentation.Api.StreamTransformer";
23+
24+
/// <summary>
25+
/// Tag/baggage name used to annotate spans with the user's EUID value.
26+
/// </summary>
27+
public const string UserEuidAttributeName = "user.euid";
28+
}

src/api/Elastic.Documentation.Api.Infrastructure/Adapters/AskAi/StreamTransformerBase.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public abstract class StreamTransformerBase(ILogger logger) : IStreamTransformer
2020
protected ILogger Logger { get; } = logger;
2121

2222
// ActivitySource for tracing streaming operations
23-
private static readonly ActivitySource StreamTransformerActivitySource = new("Elastic.Documentation.Api.StreamTransformer");
23+
private static readonly ActivitySource StreamTransformerActivitySource = new(TelemetryConstants.StreamTransformerSourceName);
2424

2525
/// <summary>
2626
/// Get the agent ID for this transformer
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics;
6+
using Elastic.Documentation.Api.Core;
7+
using OpenTelemetry;
8+
9+
namespace Elastic.Documentation.Api.Infrastructure;
10+
11+
/// <summary>
12+
/// OpenTelemetry span processor that automatically adds user.euid tag to all spans
13+
/// when it exists in the activity baggage.
14+
/// This ensures the euid is present on all spans (root and children) without manual propagation.
15+
/// </summary>
16+
public class EuidSpanProcessor : BaseProcessor<Activity>
17+
{
18+
public override void OnStart(Activity activity)
19+
{
20+
// Check if euid exists in baggage (set by ASP.NET Core request enrichment)
21+
var euid = activity.GetBaggageItem(TelemetryConstants.UserEuidAttributeName);
22+
if (!string.IsNullOrEmpty(euid))
23+
{
24+
// Add as a tag to this span if not already present
25+
var hasEuidTag = activity.TagObjects.Any(t => t.Key == TelemetryConstants.UserEuidAttributeName);
26+
if (!hasEuidTag)
27+
{
28+
_ = activity.SetTag(TelemetryConstants.UserEuidAttributeName, euid);
29+
}
30+
}
31+
}
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using Elastic.Documentation.Api.Core;
6+
using Microsoft.AspNetCore.Builder;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.Extensions.Logging;
9+
10+
namespace Elastic.Documentation.Api.Infrastructure.Middleware;
11+
12+
/// <summary>
13+
/// Middleware that adds the euid cookie value to the logging scope for all subsequent log entries in the request.
14+
/// </summary>
15+
public class EuidLoggingMiddleware(RequestDelegate next, ILogger<EuidLoggingMiddleware> logger)
16+
{
17+
public async Task InvokeAsync(HttpContext context)
18+
{
19+
// Try to get the euid cookie
20+
if (context.Request.Cookies.TryGetValue("euid", out var euid) && !string.IsNullOrEmpty(euid))
21+
{
22+
// Add euid to logging scope so it appears in all log entries for this request
23+
using (logger.BeginScope(new Dictionary<string, object> { [TelemetryConstants.UserEuidAttributeName] = euid }))
24+
{
25+
await next(context);
26+
}
27+
}
28+
else
29+
{
30+
await next(context);
31+
}
32+
}
33+
}
34+
35+
/// <summary>
36+
/// Extension methods for registering the EuidLoggingMiddleware.
37+
/// </summary>
38+
public static class EuidLoggingMiddlewareExtensions
39+
{
40+
/// <summary>
41+
/// Adds the EuidLoggingMiddleware to the application pipeline.
42+
/// This middleware enriches logs with the euid cookie value.
43+
/// </summary>
44+
public static IApplicationBuilder UseEuidLogging(this IApplicationBuilder app) => app.UseMiddleware<EuidLoggingMiddleware>();
45+
}

src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetryExtensions.cs

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
44

5+
using System.Diagnostics;
6+
using Elastic.Documentation.Api.Core;
57
using Elastic.OpenTelemetry;
8+
using Microsoft.AspNetCore.Http;
69
using Microsoft.Extensions.Hosting;
710
using OpenTelemetry;
811
using OpenTelemetry.Metrics;
@@ -12,6 +15,35 @@ namespace Elastic.Documentation.Api.Infrastructure;
1215

1316
public static class OpenTelemetryExtensions
1417
{
18+
/// <summary>
19+
/// Configures tracing for the Docs API with sources, instrumentation, and enrichment.
20+
/// This is the shared configuration used in both production and tests.
21+
/// </summary>
22+
public static TracerProviderBuilder AddDocsApiTracing(this TracerProviderBuilder builder)
23+
{
24+
_ = builder
25+
.AddSource(TelemetryConstants.AskAiSourceName)
26+
.AddSource(TelemetryConstants.StreamTransformerSourceName)
27+
.AddAspNetCoreInstrumentation(aspNetCoreOptions =>
28+
{
29+
// Enrich spans with custom attributes from HTTP context
30+
aspNetCoreOptions.EnrichWithHttpRequest = (activity, httpRequest) =>
31+
{
32+
// Add euid cookie value to span attributes and baggage
33+
if (httpRequest.Cookies.TryGetValue("euid", out var euid) && !string.IsNullOrEmpty(euid))
34+
{
35+
_ = activity.SetTag(TelemetryConstants.UserEuidAttributeName, euid);
36+
// Add to baggage so it propagates to all child spans
37+
_ = activity.AddBaggage(TelemetryConstants.UserEuidAttributeName, euid);
38+
}
39+
};
40+
})
41+
.AddProcessor<EuidSpanProcessor>() // Automatically add euid to all child spans
42+
.AddHttpClientInstrumentation();
43+
44+
return builder;
45+
}
46+
1547
/// <summary>
1648
/// Configures Elastic OpenTelemetry (EDOT) for the Docs API.
1749
/// Only enables if OTEL_EXPORTER_OTLP_ENDPOINT environment variable is set.
@@ -31,14 +63,7 @@ public static TBuilder AddDocsApiOpenTelemetry<TBuilder>(
3163
{
3264
_ = edotBuilder
3365
.WithLogging()
34-
.WithTracing(tracing =>
35-
{
36-
_ = tracing
37-
.AddSource("Elastic.Documentation.Api.AskAi")
38-
.AddSource("Elastic.Documentation.Api.StreamTransformer")
39-
.AddAspNetCoreInstrumentation()
40-
.AddHttpClientInstrumentation();
41-
})
66+
.WithTracing(tracing => tracing.AddDocsApiTracing())
4267
.WithMetrics(metrics =>
4368
{
4469
_ = metrics

src/api/Elastic.Documentation.Api.Lambda/Program.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Elastic.Documentation.Api.Core.AskAi;
99
using Elastic.Documentation.Api.Core.Search;
1010
using Elastic.Documentation.Api.Infrastructure;
11+
using Elastic.Documentation.Api.Infrastructure.Middleware;
1112

1213
try
1314
{
@@ -37,6 +38,10 @@
3738

3839
builder.Services.AddElasticDocsApiUsecases(environment);
3940
var app = builder.Build();
41+
42+
// Add middleware to enrich logs with euid cookie
43+
_ = app.UseEuidLogging();
44+
4045
var v1 = app.MapGroup("/docs/_api/v1");
4146
v1.MapElasticDocsApiEndpoints();
4247
Console.WriteLine("API endpoints mapped");
@@ -58,3 +63,8 @@
5863
[JsonSerializable(typeof(SearchRequest))]
5964
[JsonSerializable(typeof(SearchResponse))]
6065
internal sealed partial class LambdaJsonSerializerContext : JsonSerializerContext;
66+
67+
// Make the Program class accessible for integration testing
68+
#pragma warning disable ASP0027
69+
public partial class Program { }
70+
#pragma warning restore ASP0027
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
</PropertyGroup>
6+
7+
<ItemGroup>
8+
<ProjectReference Include="$(SolutionRoot)\src\api\Elastic.Documentation.Api.Lambda\Elastic.Documentation.Api.Lambda.csproj"/>
9+
<ProjectReference Include="$(SolutionRoot)\src\api\Elastic.Documentation.Api.Infrastructure\Elastic.Documentation.Api.Infrastructure.csproj"/>
10+
<ProjectReference Include="$(SolutionRoot)\src\api\Elastic.Documentation.Api.Core\Elastic.Documentation.Api.Core.csproj"/>
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<PackageReference Include="FakeItEasy" />
15+
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
16+
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
17+
</ItemGroup>
18+
19+
</Project>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics;
6+
using System.Text;
7+
using Elastic.Documentation.Api.Core;
8+
using Elastic.Documentation.Api.IntegrationTests.Fixtures;
9+
using FluentAssertions;
10+
11+
namespace Elastic.Documentation.Api.IntegrationTests;
12+
13+
/// <summary>
14+
/// Integration tests for euid cookie enrichment in OpenTelemetry traces and logging.
15+
/// Uses WebApplicationFactory to test the real API configuration with mocked services.
16+
/// </summary>
17+
public class EuidEnrichmentIntegrationTests(ApiWebApplicationFactory factory) : IClassFixture<ApiWebApplicationFactory>
18+
{
19+
private readonly ApiWebApplicationFactory _factory = factory;
20+
21+
/// <summary>
22+
/// Test that verifies euid cookie is added to both HTTP span and custom AskAi span,
23+
/// and appears in log entries - using the real API configuration.
24+
/// </summary>
25+
[Fact]
26+
public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs()
27+
{
28+
// Arrange
29+
const string expectedEuid = "integration-test-euid-12345";
30+
31+
// Create client
32+
using var client = _factory.CreateClient();
33+
34+
// Act - Make request to /ask-ai/stream with euid cookie
35+
using var request = new HttpRequestMessage(HttpMethod.Post, "/docs/_api/v1/ask-ai/stream");
36+
request.Headers.Add("Cookie", $"euid={expectedEuid}");
37+
request.Content = new StringContent(
38+
"""{"message":"test question","conversationId":null}""",
39+
Encoding.UTF8,
40+
"application/json"
41+
);
42+
43+
using var response = await client.SendAsync(request, TestContext.Current.CancellationToken);
44+
45+
// Assert - Response is successful
46+
response.IsSuccessStatusCode.Should().BeTrue();
47+
48+
// Assert - Verify spans were captured
49+
var activities = _factory.ExportedActivities;
50+
activities.Should().NotBeEmpty("OpenTelemetry should have captured activities");
51+
52+
// Verify HTTP span has euid
53+
var httpSpan = activities.FirstOrDefault(a =>
54+
a.DisplayName.Contains("POST") && a.DisplayName.Contains("ask-ai"));
55+
httpSpan.Should().NotBeNull("Should have captured HTTP request span");
56+
var httpEuidTag = httpSpan!.TagObjects.FirstOrDefault(t => t.Key == TelemetryConstants.UserEuidAttributeName);
57+
httpEuidTag.Should().NotBeNull("HTTP span should have user.euid tag");
58+
httpEuidTag.Value.Should().Be(expectedEuid, "HTTP span euid should match cookie value");
59+
60+
// Verify custom AskAi span has euid (proves baggage + processor work)
61+
var askAiSpan = activities.FirstOrDefault(a => a.Source.Name == TelemetryConstants.AskAiSourceName);
62+
askAiSpan.Should().NotBeNull("Should have captured custom AskAi span from AskAiUsecase");
63+
var askAiEuidTag = askAiSpan!.TagObjects.FirstOrDefault(t => t.Key == TelemetryConstants.UserEuidAttributeName);
64+
askAiEuidTag.Should().NotBeNull("AskAi span should have user.euid tag from baggage");
65+
askAiEuidTag.Value.Should().Be(expectedEuid, "AskAi span euid should match cookie value");
66+
67+
// Assert - Verify logs have euid in scope
68+
var logEntries = _factory.LogEntries;
69+
logEntries.Should().NotBeEmpty("Should have captured log entries");
70+
71+
// Find a log entry from AskAiUsecase
72+
var askAiLog = logEntries.FirstOrDefault(e =>
73+
e.CategoryName.Contains("AskAiUsecase") &&
74+
e.Message.Contains("Starting AskAI"));
75+
askAiLog.Should().NotBeNull("Should have logged from AskAiUsecase");
76+
77+
// Verify euid is in the logging scope
78+
var hasEuidInScope = askAiLog!.Scopes
79+
.Any(scope => scope is IDictionary<string, object> dict &&
80+
dict.TryGetValue(TelemetryConstants.UserEuidAttributeName, out var value) &&
81+
value?.ToString() == expectedEuid);
82+
83+
hasEuidInScope.Should().BeTrue("Log entry should have user.euid in scope from middleware");
84+
}
85+
}

0 commit comments

Comments
 (0)