diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6688a7bc47..4ef14d6c76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
### Features
+- Serilog scope properties are now sent with Sentry events ([#3976](https://github.com/getsentry/sentry-dotnet/pull/3976))
- The sample seed used for sampling decisions is now propagated, for use in downstream custom trace samplers ([#3951](https://github.com/getsentry/sentry-dotnet/pull/3951))
### Fixes
diff --git a/src/Sentry.Serilog/SentryOptionExtensions.cs b/src/Sentry.Serilog/SentryOptionExtensions.cs
new file mode 100644
index 0000000000..94dfbc5212
--- /dev/null
+++ b/src/Sentry.Serilog/SentryOptionExtensions.cs
@@ -0,0 +1,21 @@
+namespace Sentry.Serilog;
+
+///
+/// Extensions for to add Serilog specific configuration.
+///
+public static class SentryOptionExtensions
+{
+ ///
+ /// Ensures Serilog scope properties get applied to Sentry events. If you are not initialising Sentry when
+ /// configuring the Sentry sink for Serilog then you should call this method in the options callback for whichever
+ /// Sentry integration you are using to initialise Sentry.
+ ///
+ ///
+ ///
+ ///
+ public static T ApplySerilogScopeToEvents(this T options) where T : SentryOptions
+ {
+ options.AddEventProcessor(new SerilogScopeEventProcessor(options));
+ return options;
+ }
+}
diff --git a/src/Sentry.Serilog/SentrySinkExtensions.cs b/src/Sentry.Serilog/SentrySinkExtensions.cs
index f023819a41..3eb5b56c3a 100644
--- a/src/Sentry.Serilog/SentrySinkExtensions.cs
+++ b/src/Sentry.Serilog/SentrySinkExtensions.cs
@@ -326,6 +326,14 @@ internal static void ConfigureSentrySerilogOptions(
sentrySerilogOptions.DefaultTags.Add(tag.Key, tag.Value);
}
}
+
+ // This only works when the SDK is initialized using the LoggerSinkConfiguration extensions. If the SDK is
+ // initialized using some other integration then the processor will need to be added manually to whichever
+ // options are used to initialize the SDK.
+ if (sentrySerilogOptions.InitializeSdk)
+ {
+ sentrySerilogOptions.ApplySerilogScopeToEvents();
+ }
}
///
diff --git a/src/Sentry.Serilog/SerilogScopeEventProcessor.cs b/src/Sentry.Serilog/SerilogScopeEventProcessor.cs
new file mode 100644
index 0000000000..4b3de83fe0
--- /dev/null
+++ b/src/Sentry.Serilog/SerilogScopeEventProcessor.cs
@@ -0,0 +1,57 @@
+using Serilog.Context;
+
+namespace Sentry.Serilog;
+
+///
+/// Sentry event processor that applies properties from the Serilog scope to Sentry events.
+///
+internal class SerilogScopeEventProcessor : ISentryEventProcessor
+{
+ private readonly SentryOptions _options;
+
+ ///
+ /// This processor extracts properties from the Serilog context and applies these to Sentry events.
+ ///
+ public SerilogScopeEventProcessor(SentryOptions options)
+ {
+ _options = options;
+ _options.LogDebug("Initializing Serilog scope event processor.");
+ }
+
+ ///
+ public SentryEvent Process(SentryEvent @event)
+ {
+ _options.LogDebug("Running Serilog scope event processor on: Event {0}", @event.EventId);
+
+ // This is a bit of a hack. Serilog doesn't have any hooks that let us inspect the context. We can, however,
+ // apply the context to a dummy log event and then copy across the properties from that log event to our Sentry
+ // event.
+ // See: https://github.com/getsentry/sentry-dotnet/issues/3544#issuecomment-2307884977
+ var enricher = LogContext.Clone();
+ var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Error, null, MessageTemplate.Empty, []);
+ enricher.Enrich(logEvent, new LogEventPropertyFactory());
+ foreach (var (key, value) in logEvent.Properties)
+ {
+ if (!@event.Tags.ContainsKey(key))
+ {
+ // Potentially we could be doing SetData here instead of SetTag. See DefaultSentryScopeStateProcessor.
+ @event.SetTag(
+ key,
+ value is ScalarValue { Value: string stringValue }
+ ? stringValue
+ : value.ToString()
+ );
+ }
+ }
+ return @event;
+ }
+
+ private class LogEventPropertyFactory : ILogEventPropertyFactory
+ {
+ public LogEventProperty CreateProperty(string name, object? value, bool destructureObjects = false)
+ {
+ var scalarValue = new ScalarValue(value);
+ return new LogEventProperty(name, scalarValue);
+ }
+ }
+}
diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt
index 893bc8a67e..1455bbc51b 100644
--- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt
+++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet8_0.verified.txt
@@ -1,6 +1,11 @@
[assembly: System.CLSCompliant(true)]
namespace Sentry.Serilog
{
+ public static class SentryOptionExtensions
+ {
+ public static T ApplySerilogScopeToEvents(this T options)
+ where T : Sentry.SentryOptions { }
+ }
public class SentrySerilogOptions : Sentry.SentryOptions
{
public SentrySerilogOptions() { }
diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
index 893bc8a67e..1455bbc51b 100644
--- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
+++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.DotNet9_0.verified.txt
@@ -1,6 +1,11 @@
[assembly: System.CLSCompliant(true)]
namespace Sentry.Serilog
{
+ public static class SentryOptionExtensions
+ {
+ public static T ApplySerilogScopeToEvents(this T options)
+ where T : Sentry.SentryOptions { }
+ }
public class SentrySerilogOptions : Sentry.SentryOptions
{
public SentrySerilogOptions() { }
diff --git a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
index 893bc8a67e..1455bbc51b 100644
--- a/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
+++ b/test/Sentry.Serilog.Tests/ApiApprovalTests.Run.Net4_8.verified.txt
@@ -1,6 +1,11 @@
[assembly: System.CLSCompliant(true)]
namespace Sentry.Serilog
{
+ public static class SentryOptionExtensions
+ {
+ public static T ApplySerilogScopeToEvents(this T options)
+ where T : Sentry.SentryOptions { }
+ }
public class SentrySerilogOptions : Sentry.SentryOptions
{
public SentrySerilogOptions() { }
diff --git a/test/Sentry.Serilog.Tests/SerilogScopeEventProcessorTests.cs b/test/Sentry.Serilog.Tests/SerilogScopeEventProcessorTests.cs
new file mode 100644
index 0000000000..378849dc9f
--- /dev/null
+++ b/test/Sentry.Serilog.Tests/SerilogScopeEventProcessorTests.cs
@@ -0,0 +1,32 @@
+using Microsoft.Extensions.Logging;
+
+namespace Sentry.Serilog.Tests;
+
+public class SerilogScopeEventProcessorTests
+{
+ [Theory]
+ [InlineData("42", "42")]
+ [InlineData(42, "42")]
+ public void Emit_WithException_CreatesEventWithException(object value, string expected)
+ {
+ // Arrange
+ var options = new SentryOptions();
+ var sut = new SerilogScopeEventProcessor(options);
+
+ using var log = new LoggerConfiguration().CreateLogger();
+ var factory = new LoggerFactory().AddSerilog(log);
+ var logger = factory.CreateLogger();
+
+ // Act
+ SentryEvent evt;
+ using (logger.BeginScope(new Dictionary { ["Answer"] = value }))
+ {
+ evt = new SentryEvent();
+ sut.Process(evt);
+ }
+
+ // Assert
+ evt.Tags.Should().ContainKey("Answer");
+ evt.Tags["Answer"].Should().Be(expected);
+ }
+}