From 126e2b60b0676c8477b62ed9fe3c5d3bb083cce2 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 18 Oct 2025 23:16:36 +0000
Subject: [PATCH 1/3] Initial plan
From f63ff6a57e4776930e0e04f14a4b6dd7968c6db3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 18 Oct 2025 23:22:15 +0000
Subject: [PATCH 2/3] Add UserClaims enricher with tests and documentation
Co-authored-by: mo-esmp <1659032+mo-esmp@users.noreply.github.com>
---
README.md | 66 +++-
.../Enrichers/UserClaimsEnricher.cs | 70 ++++
...ClientInfoLoggerConfigurationExtensions.cs | 17 +
.../UserClaimsEnricherTests.cs | 310 ++++++++++++++++++
4 files changed, 462 insertions(+), 1 deletion(-)
create mode 100644 src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs
create mode 100644 test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs
diff --git a/README.md b/README.md
index e28fbe7..a0a5fe1 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# serilog-enrichers-clientinfo [](https://www.nuget.org/packages/Serilog.Enrichers.ClientInfo/) [](Serilog.Enrichers.ClientInfo)
-Enrich logs with client IP, Correlation Id and HTTP request headers.
+Enrich logs with client IP, Correlation Id, HTTP request headers, and user claims.
Install the _Serilog.Enrichers.ClientInfo_ [NuGet package](https://www.nuget.org/packages/Serilog.Enrichers.ClientInfo/)
@@ -19,6 +19,7 @@ Log.Logger = new LoggerConfiguration()
.Enrich.WithClientIp()
.Enrich.WithCorrelationId()
.Enrich.WithRequestHeader("Header-Name1")
+ .Enrich.WithUserClaims(ClaimTypes.NameIdentifier, ClaimTypes.Email)
// ...other configuration...
.CreateLogger();
```
@@ -35,6 +36,10 @@ or in `appsettings.json` file:
{
"Name": "WithRequestHeader",
"Args": { "headerName": "User-Agent"}
+ },
+ {
+ "Name": "WithUserClaims",
+ "Args": { "claimNames": ["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] }
}
],
"WriteTo": [
@@ -178,6 +183,65 @@ Log.Logger = new LoggerConfiguration()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] {Level:u3} {UserAgent} {Message:lj}{NewLine}{Exception}")
```
+### UserClaims
+The `UserClaims` enricher allows you to log specific user claim values from authenticated users. This is useful for tracking user-specific information in your logs.
+
+#### Basic Usage
+```csharp
+using System.Security.Claims;
+
+Log.Logger = new LoggerConfiguration()
+ .Enrich.WithUserClaims(ClaimTypes.NameIdentifier, ClaimTypes.Email)
+ ...
+```
+
+or in `appsettings.json` file:
+```json
+{
+ "Serilog": {
+ "MinimumLevel": "Debug",
+ "Using": [ "Serilog.Enrichers.ClientInfo" ],
+ "Enrich": [
+ {
+ "Name": "WithUserClaims",
+ "Args": {
+ "claimNames": [
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
+ ]
+ }
+ }
+ ]
+ }
+}
+```
+
+#### Features
+- **Configurable Claims**: Specify which claims to log by providing claim names as parameters.
+- **Null-Safe**: If a claim doesn't exist, it will be logged as `null` instead of throwing an error.
+- **Authentication-Aware**: Only logs claims when the user is authenticated. If the user is not authenticated, no claim properties are added to the log.
+- **Performance-Optimized**: Claim values are cached per request for better performance.
+
+#### Example with Multiple Claims
+```csharp
+Log.Logger = new LoggerConfiguration()
+ .Enrich.WithUserClaims(
+ ClaimTypes.NameIdentifier,
+ ClaimTypes.Email,
+ ClaimTypes.Name,
+ ClaimTypes.Role)
+ .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] User: {http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier} {Message:lj}{NewLine}{Exception}")
+ ...
+```
+
+#### Custom Claims
+You can also log custom claim types:
+```csharp
+Log.Logger = new LoggerConfiguration()
+ .Enrich.WithUserClaims("tenant_id", "organization_id")
+ ...
+```
+
## Installing into an ASP.NET Core Web Application
You need to register the `IHttpContextAccessor` singleton so the enrichers have access to the requests `HttpContext` to extract client IP and client agent.
This is what your `Startup` class should contain in order for this enricher to work as expected:
diff --git a/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs
new file mode 100644
index 0000000..3bbf455
--- /dev/null
+++ b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs
@@ -0,0 +1,70 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Serilog.Core;
+using Serilog.Events;
+
+namespace Serilog.Enrichers;
+
+///
+public class UserClaimsEnricher : ILogEventEnricher
+{
+ private readonly IHttpContextAccessor _contextAccessor;
+ private readonly string[] _claimNames;
+ private readonly Dictionary _claimItemKeys;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The names of the claims to log.
+ public UserClaimsEnricher(params string[] claimNames)
+ : this(new HttpContextAccessor(), claimNames)
+ {
+ }
+
+ internal UserClaimsEnricher(IHttpContextAccessor contextAccessor, params string[] claimNames)
+ {
+ _contextAccessor = contextAccessor;
+ _claimNames = claimNames ?? [];
+ _claimItemKeys = new Dictionary();
+
+ // Pre-compute item keys for each claim
+ foreach (string claimName in _claimNames)
+ {
+ _claimItemKeys[claimName] = $"Serilog_UserClaim_{claimName}";
+ }
+ }
+
+ ///
+ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
+ {
+ HttpContext httpContext = _contextAccessor.HttpContext;
+ if (httpContext == null) return;
+
+ ClaimsPrincipal user = httpContext.User;
+ if (user == null || !user.Identity?.IsAuthenticated == true) return;
+
+ foreach (string claimName in _claimNames)
+ {
+ string itemKey = _claimItemKeys[claimName];
+
+ // Check if property already exists in HttpContext.Items
+ if (httpContext.Items.TryGetValue(itemKey, out object value) &&
+ value is LogEventProperty logEventProperty)
+ {
+ logEvent.AddPropertyIfAbsent(logEventProperty);
+ continue;
+ }
+
+ // Get claim value (null if not found)
+ string claimValue = user.FindFirst(claimName)?.Value;
+
+ // Create log property with the claim name as the property name
+ LogEventProperty claimProperty = new(claimName, new ScalarValue(claimValue));
+ httpContext.Items.Add(itemKey, claimProperty);
+
+ logEvent.AddPropertyIfAbsent(claimProperty);
+ }
+ }
+}
diff --git a/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs b/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs
index e943688..73522ca 100644
--- a/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs
+++ b/src/Serilog.Enrichers.ClientInfo/Extensions/ClientInfoLoggerConfigurationExtensions.cs
@@ -85,4 +85,21 @@ public static LoggerConfiguration WithRequestHeader(this LoggerEnrichmentConfigu
return enrichmentConfiguration.With(new ClientHeaderEnricher(headerName, propertyName));
}
+
+ ///
+ /// Registers the user claims enricher to enrich logs with specified user claim values.
+ ///
+ /// The enrichment configuration.
+ /// The names of the claims to log.
+ /// enrichmentConfiguration
+ /// claimNames
+ /// The logger configuration so that multiple calls can be chained.
+ public static LoggerConfiguration WithUserClaims(this LoggerEnrichmentConfiguration enrichmentConfiguration,
+ params string[] claimNames)
+ {
+ ArgumentNullException.ThrowIfNull(enrichmentConfiguration, nameof(enrichmentConfiguration));
+ ArgumentNullException.ThrowIfNull(claimNames, nameof(claimNames));
+
+ return enrichmentConfiguration.With(new UserClaimsEnricher(claimNames));
+ }
}
\ No newline at end of file
diff --git a/test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs b/test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs
new file mode 100644
index 0000000..f681adb
--- /dev/null
+++ b/test/Serilog.Enrichers.ClientInfo.Tests/UserClaimsEnricherTests.cs
@@ -0,0 +1,310 @@
+using Microsoft.AspNetCore.Http;
+using NSubstitute;
+using Serilog.Core;
+using Serilog.Events;
+using System;
+using System.Security.Claims;
+using Xunit;
+
+namespace Serilog.Enrichers.ClientInfo.Tests;
+
+public class UserClaimsEnricherTests
+{
+ private readonly IHttpContextAccessor _contextAccessor;
+
+ public UserClaimsEnricherTests()
+ {
+ DefaultHttpContext httpContext = new();
+ _contextAccessor = Substitute.For();
+ _contextAccessor.HttpContext.Returns(httpContext);
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WhenUserIsAuthenticated_ShouldCreateClaimProperties()
+ {
+ // Arrange
+ string userId = "user123";
+ string userEmail = "user@example.com";
+
+ ClaimsIdentity identity = new(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, userId),
+ new Claim(ClaimTypes.Email, userEmail)
+ }, "TestAuth");
+
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier, ClaimTypes.Email);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ Assert.Equal(userId, evt.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString());
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.Email));
+ Assert.Equal(userEmail, evt.Properties[ClaimTypes.Email].LiteralValue().ToString());
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WhenClaimDoesNotExist_ShouldLogNullValue()
+ {
+ // Arrange
+ string userId = "user123";
+
+ ClaimsIdentity identity = new(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, userId)
+ }, "TestAuth");
+
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier, ClaimTypes.Email);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ Assert.Equal(userId, evt.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString());
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.Email));
+ Assert.Null(evt.Properties[ClaimTypes.Email].LiteralValue());
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WhenUserIsNotAuthenticated_ShouldNotAddProperties()
+ {
+ // Arrange
+ ClaimsIdentity identity = new(); // Not authenticated
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WhenUserIsNull_ShouldNotAddProperties()
+ {
+ // Arrange
+ _contextAccessor.HttpContext!.User = null;
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.False(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WhenHttpContextIsNull_ShouldNotThrow()
+ {
+ // Arrange
+ IHttpContextAccessor nullContextAccessor = Substitute.For();
+ nullContextAccessor.HttpContext.Returns((HttpContext)null);
+
+ UserClaimsEnricher userClaimsEnricher = new(nullContextAccessor, ClaimTypes.NameIdentifier);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ Exception exception = Record.Exception(() => log.Information("Test log message."));
+
+ // Assert
+ Assert.Null(exception);
+ Assert.NotNull(evt);
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WhenCalledMultipleTimes_ShouldUseCachedValue()
+ {
+ // Arrange
+ string userId = "user123";
+
+ ClaimsIdentity identity = new(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, userId)
+ }, "TestAuth");
+
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, ClaimTypes.NameIdentifier);
+
+ LogEvent evt1 = null;
+ LogEvent evt2 = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => { evt1 ??= e; evt2 = e; }))
+ .CreateLogger();
+
+ // Act
+ log.Information("First log message.");
+ log.Information("Second log message.");
+
+ // Assert
+ Assert.NotNull(evt1);
+ Assert.NotNull(evt2);
+ Assert.True(evt1.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ Assert.True(evt2.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ Assert.Equal(userId, evt1.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString());
+ Assert.Equal(userId, evt2.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString());
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WithMultipleClaims_ShouldLogAllSpecifiedClaims()
+ {
+ // Arrange
+ string userId = "user123";
+ string userEmail = "user@example.com";
+ string userName = "John Doe";
+ string userRole = "Admin";
+
+ ClaimsIdentity identity = new(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, userId),
+ new Claim(ClaimTypes.Email, userEmail),
+ new Claim(ClaimTypes.Name, userName),
+ new Claim(ClaimTypes.Role, userRole)
+ }, "TestAuth");
+
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor,
+ ClaimTypes.NameIdentifier,
+ ClaimTypes.Email,
+ ClaimTypes.Name,
+ ClaimTypes.Role);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.NameIdentifier));
+ Assert.Equal(userId, evt.Properties[ClaimTypes.NameIdentifier].LiteralValue().ToString());
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.Email));
+ Assert.Equal(userEmail, evt.Properties[ClaimTypes.Email].LiteralValue().ToString());
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.Name));
+ Assert.Equal(userName, evt.Properties[ClaimTypes.Name].LiteralValue().ToString());
+ Assert.True(evt.Properties.ContainsKey(ClaimTypes.Role));
+ Assert.Equal(userRole, evt.Properties[ClaimTypes.Role].LiteralValue().ToString());
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WithEmptyClaimArray_ShouldNotAddProperties()
+ {
+ // Arrange
+ ClaimsIdentity identity = new(new[]
+ {
+ new Claim(ClaimTypes.NameIdentifier, "user123")
+ }, "TestAuth");
+
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.DoesNotContain(ClaimTypes.NameIdentifier, evt.Properties.Keys);
+ }
+
+ [Fact]
+ public void WithUserClaims_ThenLoggerIsCalled_ShouldNotThrowException()
+ {
+ // Arrange
+ Logger logger = new LoggerConfiguration()
+ .Enrich.WithUserClaims(ClaimTypes.NameIdentifier)
+ .WriteTo.Sink(new DelegatingSink(_ => { }))
+ .CreateLogger();
+
+ // Act
+ Exception exception = Record.Exception(() => logger.Information("LOG"));
+
+ // Assert
+ Assert.Null(exception);
+ }
+
+ [Fact]
+ public void EnrichLogWithUserClaims_WithCustomClaimType_ShouldLogCustomClaim()
+ {
+ // Arrange
+ string customClaimType = "custom_claim_type";
+ string customClaimValue = "custom_value";
+
+ ClaimsIdentity identity = new(new[]
+ {
+ new Claim(customClaimType, customClaimValue)
+ }, "TestAuth");
+
+ _contextAccessor.HttpContext!.User = new ClaimsPrincipal(identity);
+
+ UserClaimsEnricher userClaimsEnricher = new(_contextAccessor, customClaimType);
+
+ LogEvent evt = null;
+ Logger log = new LoggerConfiguration()
+ .Enrich.With(userClaimsEnricher)
+ .WriteTo.Sink(new DelegatingSink(e => evt = e))
+ .CreateLogger();
+
+ // Act
+ log.Information("Test log message.");
+
+ // Assert
+ Assert.NotNull(evt);
+ Assert.True(evt.Properties.ContainsKey(customClaimType));
+ Assert.Equal(customClaimValue, evt.Properties[customClaimType].LiteralValue().ToString());
+ }
+}
From f34420992479b603403c78519174febea210b7f8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 18 Oct 2025 23:23:24 +0000
Subject: [PATCH 3/3] Remove unused using statement
Co-authored-by: mo-esmp <1659032+mo-esmp@users.noreply.github.com>
---
src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs
index 3bbf455..6be1f23 100644
--- a/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs
+++ b/src/Serilog.Enrichers.ClientInfo/Enrichers/UserClaimsEnricher.cs
@@ -1,5 +1,4 @@
using System.Collections.Generic;
-using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Serilog.Core;