Skip to content

Commit e99e38f

Browse files
authored
Fix euid enrichment in logs and refactor folder structure (#2240)
1 parent b9d7b29 commit e99e38f

File tree

7 files changed

+85
-136
lines changed

7 files changed

+85
-136
lines changed

src/api/Elastic.Documentation.Api.Infrastructure/Middleware/EuidLoggingMiddleware.cs

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
using OpenTelemetry.Logs;
9+
10+
namespace Elastic.Documentation.Api.Infrastructure.OpenTelemetry;
11+
12+
/// <summary>
13+
/// OpenTelemetry log processor that automatically adds user.euid attribute to log records
14+
/// when it exists in the current activity's baggage.
15+
/// This ensures the euid is present on all log records when set by the ASP.NET Core instrumentation.
16+
/// </summary>
17+
public class EuidLogProcessor : BaseProcessor<LogRecord>
18+
{
19+
public override void OnEnd(LogRecord logRecord)
20+
{
21+
// Check if euid already exists as an attribute
22+
var hasEuidAttribute = logRecord.Attributes?.Any(a =>
23+
a.Key == TelemetryConstants.UserEuidAttributeName) ?? false;
24+
25+
if (hasEuidAttribute)
26+
{
27+
return;
28+
}
29+
30+
// Read euid from current activity baggage (set by ASP.NET Core request enrichment)
31+
var euid = Activity.Current?.GetBaggageItem(TelemetryConstants.UserEuidAttributeName);
32+
if (!string.IsNullOrEmpty(euid))
33+
{
34+
// Add euid as an attribute to this log record
35+
var newAttributes = new List<KeyValuePair<string, object?>>(logRecord.Attributes ?? [])
36+
{
37+
new(TelemetryConstants.UserEuidAttributeName, euid)
38+
};
39+
logRecord.Attributes = newAttributes;
40+
}
41+
}
42+
}

src/api/Elastic.Documentation.Api.Infrastructure/EuidSpanProcessor.cs renamed to src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/EuidSpanProcessor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
using Elastic.Documentation.Api.Core;
77
using OpenTelemetry;
88

9-
namespace Elastic.Documentation.Api.Infrastructure;
9+
namespace Elastic.Documentation.Api.Infrastructure.OpenTelemetry;
1010

1111
/// <summary>
1212
/// OpenTelemetry span processor that automatically adds user.euid tag to all spans

src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetryExtensions.cs renamed to src/api/Elastic.Documentation.Api.Infrastructure/OpenTelemetry/OpenTelemetryExtensions.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,26 @@
77
using Elastic.OpenTelemetry;
88
using Microsoft.AspNetCore.Http;
99
using Microsoft.Extensions.Hosting;
10+
using Microsoft.Extensions.Logging;
1011
using OpenTelemetry;
12+
using OpenTelemetry.Logs;
1113
using OpenTelemetry.Metrics;
1214
using OpenTelemetry.Trace;
1315

14-
namespace Elastic.Documentation.Api.Infrastructure;
16+
namespace Elastic.Documentation.Api.Infrastructure.OpenTelemetry;
1517

1618
public static class OpenTelemetryExtensions
1719
{
20+
/// <summary>
21+
/// Configures logging for the Docs API with euid enrichment.
22+
/// This is the shared configuration used in both production and tests.
23+
/// </summary>
24+
public static LoggerProviderBuilder AddDocsApiLogging(this LoggerProviderBuilder builder)
25+
{
26+
_ = builder.AddProcessor<EuidLogProcessor>();
27+
return builder;
28+
}
29+
1830
/// <summary>
1931
/// Configures tracing for the Docs API with sources, instrumentation, and enrichment.
2032
/// This is the shared configuration used in both production and tests.
@@ -62,7 +74,7 @@ public static TBuilder AddDocsApiOpenTelemetry<TBuilder>(
6274
_ = builder.AddElasticOpenTelemetry(options, edotBuilder =>
6375
{
6476
_ = edotBuilder
65-
.WithLogging()
77+
.WithLogging(logging => logging.AddDocsApiLogging())
6678
.WithTracing(tracing => tracing.AddDocsApiTracing())
6779
.WithMetrics(metrics =>
6880
{

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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +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;
11+
using Elastic.Documentation.Api.Infrastructure.OpenTelemetry;
1212

1313
try
1414
{
@@ -39,9 +39,6 @@
3939
builder.Services.AddElasticDocsApiUsecases(environment);
4040
var app = builder.Build();
4141

42-
// Add middleware to enrich logs with euid cookie
43-
_ = app.UseEuidLogging();
44-
4542
var v1 = app.MapGroup("/docs/_api/v1");
4643
v1.MapElasticDocsApiEndpoints();
4744
Console.WriteLine("API endpoints mapped");

tests-integration/Elastic.Documentation.Api.IntegrationTests/EuidEnrichmentIntegrationTests.cs

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Diagnostics;
66
using System.Text;
77
using Elastic.Documentation.Api.Core;
8+
using Elastic.Documentation.Api.Core.AskAi;
89
using Elastic.Documentation.Api.IntegrationTests.Fixtures;
910
using FluentAssertions;
1011

@@ -64,22 +65,19 @@ public async Task AskAiEndpointPropagatatesEuidToAllSpansAndLogs()
6465
askAiEuidTag.Should().NotBeNull("AskAi span should have user.euid tag from baggage");
6566
askAiEuidTag.Value.Should().Be(expectedEuid, "AskAi span euid should match cookie value");
6667

67-
// Assert - Verify logs have euid in scope
68-
var logEntries = _factory.LogEntries;
69-
logEntries.Should().NotBeEmpty("Should have captured log entries");
68+
// Assert - Verify logs have euid in attributes
69+
var logRecords = _factory.ExportedLogRecords;
70+
logRecords.Should().NotBeEmpty("Should have captured log records");
7071

7172
// 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");
73+
var askAiLogRecord = logRecords.FirstOrDefault(r =>
74+
string.Equals(r.CategoryName, typeof(AskAiUsecase).FullName, StringComparison.OrdinalIgnoreCase) &&
75+
r.FormattedMessage?.Contains("Starting AskAI", StringComparison.OrdinalIgnoreCase) == true);
76+
askAiLogRecord.Should().NotBeNull("Should have logged from AskAiUsecase");
7677

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");
78+
// Verify euid is present in OTEL log attributes (mirrors production exporter behavior)
79+
var euidAttribute = askAiLogRecord!.Attributes?.FirstOrDefault(a => a.Key == TelemetryConstants.UserEuidAttributeName) ?? default;
80+
euidAttribute.Should().NotBe(default(KeyValuePair<string, object?>), "Log record should include user.euid attribute");
81+
(euidAttribute.Value?.ToString() ?? string.Empty).Should().Be(expectedEuid, "Log record euid should match cookie value");
8482
}
8583
}

tests-integration/Elastic.Documentation.Api.IntegrationTests/Fixtures/ApiWebApplicationFactory.cs

Lines changed: 15 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77
using Elastic.Documentation.Api.Core.AskAi;
88
using Elastic.Documentation.Api.Infrastructure;
99
using Elastic.Documentation.Api.Infrastructure.Aws;
10+
using Elastic.Documentation.Api.Infrastructure.OpenTelemetry;
1011
using FakeItEasy;
1112
using Microsoft.AspNetCore.Hosting;
1213
using Microsoft.AspNetCore.Mvc.Testing;
1314
using Microsoft.Extensions.DependencyInjection;
1415
using Microsoft.Extensions.Logging;
1516
using OpenTelemetry;
17+
using OpenTelemetry.Logs;
1618
using OpenTelemetry.Trace;
1719

1820
namespace Elastic.Documentation.Api.IntegrationTests.Fixtures;
@@ -24,25 +26,23 @@ namespace Elastic.Documentation.Api.IntegrationTests.Fixtures;
2426
public class ApiWebApplicationFactory : WebApplicationFactory<Program>
2527
{
2628
public List<Activity> ExportedActivities { get; } = [];
27-
public List<TestLogEntry> LogEntries { get; } = [];
28-
private readonly List<MemoryStream> MockMemoryStreams = new();
29+
public List<LogRecord> ExportedLogRecords { get; } = [];
30+
private readonly List<MemoryStream> _mockMemoryStreams = [];
2931
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
3032
builder.ConfigureServices(services =>
3133
{
32-
// Configure OpenTelemetry with in-memory exporter for testing
33-
// Uses the same production configuration via AddDocsApiTracing()
34-
_ = services.AddOpenTelemetry()
35-
.WithTracing(tracing =>
36-
{
37-
_ = tracing
38-
.AddDocsApiTracing() // Reuses production configuration
39-
.AddInMemoryExporter(ExportedActivities);
40-
});
41-
42-
// Configure logging to capture log entries
43-
_ = services.AddLogging(logging =>
34+
var otelBuilder = services.AddOpenTelemetry();
35+
_ = otelBuilder.WithTracing(tracing =>
36+
{
37+
_ = tracing
38+
.AddDocsApiTracing() // Reuses production configuration
39+
.AddInMemoryExporter(ExportedActivities);
40+
});
41+
_ = otelBuilder.WithLogging(logging =>
4442
{
45-
_ = logging.AddProvider(new TestLoggerProvider(LogEntries));
43+
_ = logging
44+
.AddDocsApiLogging() // Reuses production configuration
45+
.AddInMemoryExporter(ExportedLogRecords);
4646
});
4747

4848
// Mock IParameterProvider to avoid AWS dependencies
@@ -88,58 +88,3 @@ protected override void Dispose(bool disposing)
8888
base.Dispose(disposing);
8989
}
9090
}
91-
92-
/// <summary>
93-
/// Test logger provider for capturing log entries with scopes.
94-
/// </summary>
95-
internal sealed class TestLoggerProvider(List<TestLogEntry> logEntries) : ILoggerProvider
96-
{
97-
private readonly List<object> _sharedScopes = [];
98-
99-
public ILogger CreateLogger(string categoryName) => new TestLogger(categoryName, logEntries, _sharedScopes);
100-
public void Dispose() { }
101-
}
102-
103-
/// <summary>
104-
/// Test logger that captures log entries with their scopes.
105-
/// </summary>
106-
internal sealed class TestLogger(string categoryName, List<TestLogEntry> logEntries, List<object> sharedScopes) : ILogger
107-
{
108-
public IDisposable BeginScope<TState>(TState state) where TState : notnull
109-
{
110-
sharedScopes.Add(state);
111-
return new ScopeDisposable(sharedScopes, state);
112-
}
113-
114-
public bool IsEnabled(LogLevel logLevel) => true;
115-
116-
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
117-
{
118-
var entry = new TestLogEntry
119-
{
120-
CategoryName = categoryName,
121-
LogLevel = logLevel,
122-
Message = formatter(state, exception),
123-
Exception = exception,
124-
Scopes = [.. sharedScopes]
125-
};
126-
logEntries.Add(entry);
127-
}
128-
129-
private sealed class ScopeDisposable(List<object> scopes, object state) : IDisposable
130-
{
131-
public void Dispose() => scopes.Remove(state);
132-
}
133-
}
134-
135-
/// <summary>
136-
/// Represents a captured log entry with its scopes.
137-
/// </summary>
138-
public sealed class TestLogEntry
139-
{
140-
public required string CategoryName { get; init; }
141-
public LogLevel LogLevel { get; init; }
142-
public required string Message { get; init; }
143-
public Exception? Exception { get; init; }
144-
public List<object> Scopes { get; init; } = [];
145-
}

0 commit comments

Comments
 (0)