Repository: serilog/serilog

Issue:
Discussion: built-in trace and span ids
Trace and span correlation has become a widespread feature of diagnostics in .NET and elsewhere. If Serilog was designed today, it's more than likely that trace and span ids would be on an equal footing with levels, messages, and exceptions, as first-class attributes of log events.

## What are trace and span ids used for?

A _trace_ constitutes one or more local operations - _spans_ - that occur within a larger, distributed unit of work. Tracing systems use trace and span identifiers to build a causal, hierarchical view of work done within a system.

Annotating log events with trace and span ids mean that, when inspecting a trace, events occurring within the context of the trace or a  particular span can be readily identified.

In the absence of a dedicated tracing system, trace and span ids are still often present, and can therefore be used as a convenient way to correlate log events that were generated during a particular piece of work.

## Why give trace and span ids first-class support?

Serilog's enrichment features are frequently used to attach `TraceId`, `SpanId`, and related properties to `LogEvents`. While this can be made to work for the most part, the lack of standardized names and formats mean that sinks supporting trace and span information can't provide a reliable out-of-the-box experience for trace and span correlation.

This is akin to the situation with exceptions: having a standardized, top-level `Exception` field on all events means that Serilog sinks can all consistently and reliably record error information when it is present.

[`System.Diagnostics.Activity`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity?view=net-7.0) standardizes trace propagation on recent .NET versions. Serilog can adopt the data model and propagation features of `Activity` to interop naturally with other .NET libraries and components.

## Strawman proposal

First-class trace and span support would most likely mean adding `TraceId` and `SpanId` properties to the `LogEvent` class, along with a constructor overload accepting them. The trace and span ids would be captured using either the _System.Diagnostics_ types, or a compatible bytestring representation that can be used on all targeted platforms.

The `Logger` functionality that constructs `LogEvents` would be updated to also collect trace and span details from `Activity.Current` when possible.

Implementing first-class support would essentially entail "moving" trace and span information from regular properties to the dedicated fields. In the short term this might lead to perceived duplication where trace and span details are already collected, but this would resolve itself naturally over time.

The various output formatting mechanisms would need to be updated to directly support the new trace and span properties, as would _Serilog.Expressions_ expression evaluation.

## See also

 * https://www.w3.org/TR/trace-context/
 * https://github.com/serilog/serilog-sinks-opentelemetry/


Hints:
Hey folks! I'm increasingly of the opinion that this will turn out to be important in Serilog's future; starting to look at spiking it out, but wondering whether anyone has any reservations/points against going in this direction? Will keep you posted here with updates :-)
I find myself wondering if there's a significant benefit in having first-class support over a log enricher

```fsharp
type LogEnricher() =
  interface ILogEventEnricher with
    member this.Enrich(logEvent, propertyFactory) =
      let prop k v =
        logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty(k, v))

      let act = System.Diagnostics.Activity.Current
      if act <> null then
        act.TraceId.ToString() |> prop "trace_id"
        act.SpanId.ToString() |> prop "span_id"
```
Thanks for sharing your thoughts @nordfjord.

The challenge using enrichers is that sinks that need to consume trace and span ids can't rely on those properties coming through with consistent names or data types, so there isn't the "just works" usability I'd like.

In many cases now, identifying a trace and span is as fundamental as identifying an exception or level, so it's really something of a historical artifact that Serilog has special support for those, but not for spans.
Right, I see what you're saying. Would it make sense to align with the OpenTelemetry log data model in that case? 

https://opentelemetry.io/docs/specs/otel/logs/data-model/
👍 that's one of the key inputs; the first consumer of this change would be https://github.com/serilog-sinks-opentelemetry, which goes to some significant lengths to work around the absence of first-class trace and span ids right now.

There are some more aspects of the data model that play into how an OTLP sink might work (resource attrs etc) but not a lot of particularly important per-event data apart from these two key properties.
(The proposal talks about `System.Diagnostics.Activity`, since this is the .NET API that ties into OpenTelemetry and whatever other diagnostic data collectors might be used.)

Patch:
diff --git a/Serilog.sln b/Serilog.sln
index 066faba06..7e847867e 100644
--- a/Serilog.sln
+++ b/Serilog.sln
@@ -8,7 +8,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "assets", "assets", "{E9D1B5
 		appveyor.yml = appveyor.yml
 		Build.ps1 = Build.ps1
 		build.sh = build.sh
-		CHANGES.md = CHANGES.md
 		Directory.Build.props = Directory.Build.props
 		Directory.Build.targets = Directory.Build.targets
 		global.json = global.json
diff --git a/Serilog.sln.DotSettings b/Serilog.sln.DotSettings
index d98a03dac..b9aebc18f 100644
--- a/Serilog.sln.DotSettings
+++ b/Serilog.sln.DotSettings
@@ -218,6 +218,7 @@
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=destructure/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Enricher/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Enrichers/@EntryIndexedValue">True</s:Boolean>
+	<s:Boolean x:Key="/Default/UserDictionary/Words/=formattable/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=Obsoletion/@EntryIndexedValue">True</s:Boolean>
 	<s:Boolean x:Key="/Default/UserDictionary/Words/=stringified/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
 	
\ No newline at end of file
diff --git a/src/Serilog/Core/Logger.cs b/src/Serilog/Core/Logger.cs
index a39b91058..7ab16d433 100644
--- a/src/Serilog/Core/Logger.cs
+++ b/src/Serilog/Core/Logger.cs
@@ -12,6 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+using System.Diagnostics;
+
 #pragma warning disable Serilog004 // Constant MessageTemplate verifier
 
 namespace Serilog.Core;
@@ -79,7 +81,7 @@ internal Logger(
     /// <returns>A logger that will enrich log events as specified.</returns>
     public ILogger ForContext(ILogEventEnricher enricher)
     {
-        if (enricher == null)
+        if (enricher == null!)
             return this; // No context here, so little point writing to SelfLog.
 
         return new Logger(
@@ -102,7 +104,7 @@ public ILogger ForContext(ILogEventEnricher enricher)
     /// <returns>A logger that will enrich log events as specified.</returns>
     public ILogger ForContext(IEnumerable<ILogEventEnricher> enrichers)
     {
-        if (enrichers == null)
+        if (enrichers == null!)
             return this; // No context here, so little point writing to SelfLog.
 
         return ForContext(new SafeAggregateEnricher(enrichers));
@@ -164,7 +166,7 @@ public ILogger ForContext(string propertyName, object? value, bool destructureOb
     /// <returns>A logger that will enrich log events as specified.</returns>
     public ILogger ForContext(Type source)
     {
-        if (source == null)
+        if (source == null!)
             return this; // Little point in writing to SelfLog here because we don't have any contextual information
 
         return ForContext(Constants.SourceContextPropertyName, source.FullName);
@@ -352,7 +354,7 @@ public void Write<T0, T1, T2>(LogEventLevel level, Exception? exception, string
     public void Write(LogEventLevel level, Exception? exception, string messageTemplate, params object?[]? propertyValues)
     {
         if (!IsEnabled(level)) return;
-        if (messageTemplate == null) return;
+        if (messageTemplate == null!) return;
 
         // Catch a common pitfall when a single non-object array is cast to object[]
         if (propertyValues != null &&
@@ -362,7 +364,8 @@ public void Write(LogEventLevel level, Exception? exception, string messageTempl
         var logTimestamp = DateTimeOffset.Now;
         _messageTemplateProcessor.Process(messageTemplate, propertyValues, out var parsedTemplate, out var boundProperties);
 
-        var logEvent = new LogEvent(logTimestamp, level, exception, parsedTemplate, boundProperties);
+        var currentActivity = Activity.Current;
+        var logEvent = new LogEvent(logTimestamp, level, exception, parsedTemplate, boundProperties, currentActivity?.TraceId ?? default, currentActivity?.SpanId ?? default);
         Dispatch(logEvent);
     }
 
@@ -372,7 +375,7 @@ public void Write(LogEventLevel level, Exception? exception, string messageTempl
     /// <param name="logEvent">The event to write.</param>
     public void Write(LogEvent logEvent)
     {
-        if (logEvent == null) return;
+        if (logEvent == null!) return;
         if (!IsEnabled(logEvent.Level)) return;
         Dispatch(logEvent);
     }
diff --git a/src/Serilog/Events/LogEvent.cs b/src/Serilog/Events/LogEvent.cs
index 2950da1e7..e66d0d913 100644
--- a/src/Serilog/Events/LogEvent.cs
+++ b/src/Serilog/Events/LogEvent.cs
@@ -12,6 +12,9 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+using System.Diagnostics;
+// ReSharper disable IntroduceOptionalParameters.Global
+
 namespace Serilog.Events;
 
 /// <summary>
@@ -20,12 +23,23 @@ namespace Serilog.Events;
 public class LogEvent
 {
     readonly Dictionary<string, LogEventPropertyValue> _properties;
-
-    LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? exception, MessageTemplate messageTemplate, Dictionary<string, LogEventPropertyValue> properties)
+    ActivityTraceId _traceId;
+    ActivitySpanId _spanId;
+
+    LogEvent(
+        DateTimeOffset timestamp,
+        LogEventLevel level,
+        Exception? exception,
+        MessageTemplate messageTemplate,
+        Dictionary<string, LogEventPropertyValue> properties,
+        ActivityTraceId traceId,
+        ActivitySpanId spanId)
     {
         Timestamp = timestamp;
         Level = level;
         Exception = exception;
+        _traceId = traceId;
+        _spanId = spanId;
         MessageTemplate = Guard.AgainstNull(messageTemplate);
         _properties = Guard.AgainstNull(properties);
     }
@@ -41,12 +55,8 @@ public class LogEvent
     /// <exception cref="ArgumentNullException">When <paramref name="messageTemplate"/> is <code>null</code></exception>
     /// <exception cref="ArgumentNullException">When <paramref name="properties"/> is <code>null</code></exception>
     public LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? exception, MessageTemplate messageTemplate, IEnumerable<LogEventProperty> properties)
-        : this(timestamp, level, exception, messageTemplate, new Dictionary<string, LogEventPropertyValue>())
+        : this(timestamp, level, exception, messageTemplate, properties, default, default)
     {
-        Guard.AgainstNull(properties);
-
-        foreach (var property in properties)
-            AddOrUpdateProperty(property);
     }
 
     /// <summary>
@@ -57,10 +67,22 @@ public LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? except
     /// <param name="exception">An exception associated with the event, or null.</param>
     /// <param name="messageTemplate">The message template describing the event.</param>
     /// <param name="properties">Properties associated with the event, including those presented in <paramref name="messageTemplate"/>.</param>
+    /// <param name="traceId">The id of the trace that was active when the event was created, if any.</param>
+    /// <param name="spanId">The id of the span that was active when the event was created, if any.</param>
     /// <exception cref="ArgumentNullException">When <paramref name="messageTemplate"/> is <code>null</code></exception>
     /// <exception cref="ArgumentNullException">When <paramref name="properties"/> is <code>null</code></exception>
-    internal LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? exception, MessageTemplate messageTemplate, EventProperty[] properties)
-        : this(timestamp, level, exception, messageTemplate, new Dictionary<string, LogEventPropertyValue>(Guard.AgainstNull(properties).Length))
+    [CLSCompliant(false)]
+    public LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? exception, MessageTemplate messageTemplate, IEnumerable<LogEventProperty> properties, ActivityTraceId traceId, ActivitySpanId spanId)
+        : this(timestamp, level, exception, messageTemplate, new Dictionary<string, LogEventPropertyValue>(), traceId, spanId)
+    {
+        Guard.AgainstNull(properties);
+
+        foreach (var property in properties)
+            AddOrUpdateProperty(property);
+    }
+
+    internal LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? exception, MessageTemplate messageTemplate, EventProperty[] properties, ActivityTraceId traceId, ActivitySpanId spanId)
+        : this(timestamp, level, exception, messageTemplate, new Dictionary<string, LogEventPropertyValue>(Guard.AgainstNull(properties).Length), traceId, spanId)
     {
         for (var i = 0; i < properties.Length; ++i)
             _properties[properties[i].Name] = properties[i].Value;
@@ -76,6 +98,18 @@ internal LogEvent(DateTimeOffset timestamp, LogEventLevel level, Exception? exce
     /// </summary>
     public LogEventLevel Level { get; }
 
+    /// <summary>
+    /// The id of the trace that was active when the event was created, if any.
+    /// </summary>
+    [CLSCompliant(false)]
+    public ActivityTraceId? TraceId => _traceId == default ? null : _traceId;
+
+    /// <summary>
+    /// The id of the span that was active when the event was created, if any.
+    /// </summary>
+    [CLSCompliant(false)]
+    public ActivitySpanId? SpanId => _spanId == default ? null : _spanId;
+
     /// <summary>
     /// The message template describing the event.
     /// </summary>
@@ -201,6 +235,8 @@ internal LogEvent Copy()
             Level,
             Exception,
             MessageTemplate,
-            properties);
+            properties,
+            TraceId ?? default,
+            SpanId ?? default);
     }
 }
diff --git a/src/Serilog/Formatting/Display/MessageTemplateTextFormatter.cs b/src/Serilog/Formatting/Display/MessageTemplateTextFormatter.cs
index aca0e650c..835124883 100644
--- a/src/Serilog/Formatting/Display/MessageTemplateTextFormatter.cs
+++ b/src/Serilog/Formatting/Display/MessageTemplateTextFormatter.cs
@@ -24,6 +24,7 @@ namespace Serilog.Formatting.Display;
 /// to them. Second, tokens without matching properties are skipped
 /// rather than being written as raw text.
 /// </summary>
+/// <remarks>New code should prefer <c>ExpressionTemplate</c> from <c>Serilog.Expressions</c>.</remarks>
 public class MessageTemplateTextFormatter : ITextFormatter
 {
     readonly IFormatProvider? _formatProvider;
@@ -70,6 +71,14 @@ public void Format(LogEvent logEvent, TextWriter output)
                 var moniker = LevelOutputFormat.GetLevelMoniker(logEvent.Level, pt.Format);
                 Padding.Apply(output, moniker, pt.Alignment);
             }
+            else if (pt.PropertyName == OutputProperties.TraceIdPropertyName)
+            {
+                Padding.Apply(output, logEvent.TraceId?.ToString() ?? "", pt.Alignment);
+            }
+            else if (pt.PropertyName == OutputProperties.SpanIdPropertyName)
+            {
+                Padding.Apply(output, logEvent.SpanId?.ToString() ?? "", pt.Alignment);
+            }
             else if (pt.PropertyName == OutputProperties.NewLinePropertyName)
             {
                 Padding.Apply(output, Environment.NewLine, pt.Alignment);
diff --git a/src/Serilog/Formatting/Display/OutputProperties.cs b/src/Serilog/Formatting/Display/OutputProperties.cs
index 1f11398b4..e24cc65f2 100644
--- a/src/Serilog/Formatting/Display/OutputProperties.cs
+++ b/src/Serilog/Formatting/Display/OutputProperties.cs
@@ -35,6 +35,16 @@ public static class OutputProperties
     /// </summary>
     public const string LevelPropertyName = "Level";
 
+    /// <summary>
+    /// The id of the trace that was active at the log event's time of creation, if any.
+    /// </summary>
+    public const string TraceIdPropertyName = "TraceId";
+
+    /// <summary>
+    /// The id of the span that was active at the log event's time of creation, if any.
+    /// </summary>
+    public const string SpanIdPropertyName = "SpanId";
+
     /// <summary>
     /// A new line.
     /// </summary>
diff --git a/src/Serilog/Formatting/Json/JsonFormatter.cs b/src/Serilog/Formatting/Json/JsonFormatter.cs
index fab5e95e7..b5c7a9e89 100644
--- a/src/Serilog/Formatting/Json/JsonFormatter.cs
+++ b/src/Serilog/Formatting/Json/JsonFormatter.cs
@@ -71,6 +71,18 @@ public void Format(LogEvent logEvent, TextWriter output)
             JsonValueFormatter.WriteQuotedJsonString(message, output);
         }
 
+        if (logEvent.TraceId != null)
+        {
+            output.Write(",\"TraceId\":");
+            JsonValueFormatter.WriteQuotedJsonString(logEvent.TraceId.ToString()!, output);
+        }
+
+        if (logEvent.SpanId != null)
+        {
+            output.Write(",\"SpanId\":");
+            JsonValueFormatter.WriteQuotedJsonString(logEvent.SpanId.ToString()!, output);
+        }
+
         if (logEvent.Exception != null)
         {
             output.Write(",\"Exception\":");
diff --git a/src/Serilog/ILogger.cs b/src/Serilog/ILogger.cs
index c307d21d0..95ac8d67f 100644
--- a/src/Serilog/ILogger.cs
+++ b/src/Serilog/ILogger.cs
@@ -12,6 +12,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+using System.Diagnostics;
+
 namespace Serilog;
 
 /// <summary>
@@ -62,7 +64,7 @@ ILogger ForContext(ILogEventEnricher enricher)
     ILogger ForContext(IEnumerable<ILogEventEnricher> enrichers)
 #if FEATURE_DEFAULT_INTERFACE
     {
-        if (enrichers == null)
+        if (enrichers == null!)
             return this; // No context here, so little point writing to SelfLog.
 
         return ForContext(new SafeAggregateEnricher(enrichers));
@@ -113,7 +115,7 @@ ILogger ForContext<TSource>()
     ILogger ForContext(Type source)
 #if FEATURE_DEFAULT_INTERFACE
     {
-        if (source == null)
+        if (source == null!)
             return this; // Little point in writing to SelfLog here because we don't have any contextual information
 
         return ForContext(Constants.SourceContextPropertyName, source.FullName);
@@ -324,16 +326,19 @@ void Write(LogEventLevel level, Exception? exception, string messageTemplate, pa
 #if FEATURE_DEFAULT_INTERFACE
     {
         if (!IsEnabled(level)) return;
-        if (messageTemplate == null) return;
+        if (messageTemplate == null!) return;
 
         // Catch a common pitfall when a single non-object array is cast to object[]
         if (propertyValues != null &&
             propertyValues.GetType() != typeof(object[]))
             propertyValues = new object[] { propertyValues };
 
+        var logTimestamp = DateTimeOffset.Now;
         if (BindMessageTemplate(messageTemplate, propertyValues, out var parsedTemplate, out var boundProperties))
         {
-            Write(new LogEvent(DateTimeOffset.Now, level, exception, parsedTemplate, boundProperties));
+            var currentActivity = Activity.Current;
+            var logEvent = new LogEvent(logTimestamp, level, exception, parsedTemplate, boundProperties, currentActivity?.TraceId ?? default, currentActivity?.SpanId ?? default);
+            Write(logEvent);
         }
     }
 #else
diff --git a/src/Serilog/Serilog.csproj b/src/Serilog/Serilog.csproj
index 6ef15e6b8..b01695e9e 100644
--- a/src/Serilog/Serilog.csproj
+++ b/src/Serilog/Serilog.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
   <PropertyGroup>
     <Description>Simple .NET logging with fully-structured events</Description>
-    <VersionPrefix>3.0.2</VersionPrefix>
+    <VersionPrefix>3.1.0</VersionPrefix>
     <Authors>Serilog Contributors</Authors>
     <TargetFrameworks Condition=" '$(OS)' == 'Windows_NT'">net462;net471</TargetFrameworks>
     <TargetFrameworks>$(TargetFrameworks);netstandard2.1;netstandard2.0;net5.0;net6.0;net7.0</TargetFrameworks>
@@ -66,6 +66,19 @@
 
   <ItemGroup Condition=" '$(TargetFramework)' == 'net462' ">
     <PackageReference Include="System.ValueTuple" Version="4.5.0" />
+    <PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.2" />
+  </ItemGroup>
+
+  <ItemGroup Condition=" '$(TargetFramework)' == 'net471' ">
+    <PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.2" />
+  </ItemGroup>
+
+  <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.0' ">
+    <PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.2" />
+  </ItemGroup>
+
+  <ItemGroup Condition=" '$(TargetFramework)' == 'netstandard2.1' ">
+    <PackageReference Include="System.Diagnostics.DiagnosticSource" Version="7.0.2" />
   </ItemGroup>
 
 </Project>


Test patch:
diff --git a/test/Serilog.ApprovalTests/Serilog.approved.txt b/test/Serilog.ApprovalTests/Serilog.approved.txt
index 4aba4ae9a..1824ecc72 100644
--- a/test/Serilog.ApprovalTests/Serilog.approved.txt
+++ b/test/Serilog.ApprovalTests/Serilog.approved.txt
@@ -358,11 +358,17 @@ namespace Serilog.Events
     public class LogEvent
     {
         public LogEvent(System.DateTimeOffset timestamp, Serilog.Events.LogEventLevel level, System.Exception? exception, Serilog.Events.MessageTemplate messageTemplate, System.Collections.Generic.IEnumerable<Serilog.Events.LogEventProperty> properties) { }
+        [System.CLSCompliant(false)]
+        public LogEvent(System.DateTimeOffset timestamp, Serilog.Events.LogEventLevel level, System.Exception? exception, Serilog.Events.MessageTemplate messageTemplate, System.Collections.Generic.IEnumerable<Serilog.Events.LogEventProperty> properties, System.Diagnostics.ActivityTraceId traceId, System.Diagnostics.ActivitySpanId spanId) { }
         public System.Exception? Exception { get; }
         public Serilog.Events.LogEventLevel Level { get; }
         public Serilog.Events.MessageTemplate MessageTemplate { get; }
         public System.Collections.Generic.IReadOnlyDictionary<string, Serilog.Events.LogEventPropertyValue> Properties { get; }
+        [System.CLSCompliant(false)]
+        public System.Diagnostics.ActivitySpanId? SpanId { get; }
         public System.DateTimeOffset Timestamp { get; }
+        [System.CLSCompliant(false)]
+        public System.Diagnostics.ActivityTraceId? TraceId { get; }
         public void AddOrUpdateProperty(Serilog.Events.LogEventProperty property) { }
         public void AddPropertyIfAbsent(Serilog.Events.LogEventProperty property) { }
         public void RemovePropertyIfPresent(string propertyName) { }
@@ -452,7 +458,9 @@ namespace Serilog.Formatting.Display
         public const string MessagePropertyName = "Message";
         public const string NewLinePropertyName = "NewLine";
         public const string PropertiesPropertyName = "Properties";
+        public const string SpanIdPropertyName = "SpanId";
         public const string TimestampPropertyName = "Timestamp";
+        public const string TraceIdPropertyName = "TraceId";
     }
 }
 namespace Serilog.Formatting
diff --git a/test/Serilog.PerformanceTests/PipelineBenchmark.cs b/test/Serilog.PerformanceTests/PipelineBenchmark.cs
index 2509a0960..946dd7ed3 100644
--- a/test/Serilog.PerformanceTests/PipelineBenchmark.cs
+++ b/test/Serilog.PerformanceTests/PipelineBenchmark.cs
@@ -30,9 +30,6 @@ public void Setup()
         _log = new LoggerConfiguration()
             .WriteTo.Sink(new NullSink())
             .CreateLogger();
-
-        // Ensure template is cached
-        _log.Information(_exception, "Hello, {Name}!", "World");
     }
 
     [Benchmark]
diff --git a/test/Serilog.Tests/Core/LoggerTests.cs b/test/Serilog.Tests/Core/LoggerTests.cs
index 799139766..5b555d082 100644
--- a/test/Serilog.Tests/Core/LoggerTests.cs
+++ b/test/Serilog.Tests/Core/LoggerTests.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics;
+
 #pragma warning disable Serilog004 // Constant MessageTemplate verifier
 #pragma warning disable Serilog003 // Property binding verifier
 
@@ -5,6 +7,14 @@ namespace Serilog.Tests.Core;
 
 public class LoggerTests
 {
+    static LoggerTests()
+    {
+        // This is necessary to force activity id allocation on .NET Framework and early .NET Core versions. When this isn't
+        // done, log events end up carrying null trace and span ids (which is fine).
+        Activity.DefaultIdFormat = ActivityIdFormat.W3C;
+        Activity.ForceDefaultIdFormat = true;
+    }
+
     [Theory]
     [InlineData(typeof(Logger))]
 #if FEATURE_DEFAULT_INTERFACE
@@ -54,7 +64,9 @@ public void PropertiesInANestedContextOverrideParentContextValues()
     [Fact]
     public void ParametersForAnEmptyTemplateAreIgnored()
     {
+        // ReSharper disable StructuredMessageTemplateProblem
         var e = DelegatingSink.GetLogEvent(l => l.Error("message", new object()));
+        // ReSharper restore StructuredMessageTemplateProblem
         Assert.Equal("message", e.RenderMessage());
     }
 
@@ -99,7 +111,9 @@ public void MessageTemplatesCanBeBound(Type loggerType)
     {
         var log = CreateLogger(loggerType, lc => lc);
 
+        // ReSharper disable StructuredMessageTemplateProblem
         Assert.True(log.BindMessageTemplate("Hello, {Name}!", new object[] { "World" }, out var template, out var properties));
+        // ReSharper restore StructuredMessageTemplateProblem
 
         Assert.Equal("Hello, {Name}!", template.Text);
         Assert.Equal("World", properties.Single().Value.LiteralValue());
@@ -188,6 +202,7 @@ public void DelegatingLoggerShouldDelegateCallsToInnerLogger()
         Assert.Equal("message", collectingSink.SingleEvent.Properties["prop"].LiteralValue());
         Assert.Equal(42, collectingSink.SingleEvent.Properties["number"].LiteralValue());
         Assert.Equal(
+            // ReSharper disable once CoVariantArrayConversion
             expected: new SequenceValue(new[] { new ScalarValue(1), new ScalarValue(2), new ScalarValue(3) }),
             actual: (SequenceValue)collectingSink.SingleEvent.Properties["values"],
             comparer: new LogEventPropertyValueComparer());
@@ -260,6 +275,34 @@ public void WrappedAggregatedSinksAreDisposedWhenLoggerIsDisposed()
         Assert.True(sinkB.IsDisposed);
     }
 
+    [Fact]
+    public void CurrentActivityIsCapturedAtLogEventCreation()
+    {
+        using var listener = new ActivityListener();
+        listener.ShouldListenTo = _ => true;
+        listener.SampleUsingParentId = (ref ActivityCreationOptions<string> _) => ActivitySamplingResult.AllData;
+        listener.Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData;
+        ActivitySource.AddActivityListener(listener);
+
+        using var source = new ActivitySource(Some.String());
+        using var activity = source.StartActivity(Some.String());
+        Assert.NotNull(activity);
+        Assert.NotEqual("00000000000000000000000000000000", activity.TraceId.ToHexString());
+        Assert.NotEqual("0000000000000000", activity.SpanId.ToHexString());
+
+        var sink = new CollectingSink();
+        var log = new LoggerConfiguration()
+            .WriteTo.Sink(sink)
+            .CreateLogger();
+
+        log.Information("Hello, world!");
+
+        var single = sink.SingleEvent;
+
+        Assert.Equal(activity.TraceId, single.TraceId);
+        Assert.Equal(activity.SpanId, single.SpanId);
+    }
+
 #if FEATURE_ASYNCDISPOSABLE
 
     [Fact]
diff --git a/test/Serilog.Tests/Events/LogEventTests.cs b/test/Serilog.Tests/Events/LogEventTests.cs
new file mode 100644
index 000000000..59d35b967
--- /dev/null
+++ b/test/Serilog.Tests/Events/LogEventTests.cs
@@ -0,0 +1,17 @@
+﻿using System.Diagnostics;
+
+namespace Serilog.Tests.Events;
+
+public class LogEventTests
+{
+    [Fact]
+    public void CopiedLogEventsPropagateTraceAndSpan()
+    {
+        var traceId = ActivityTraceId.CreateRandom();
+        var spanId = ActivitySpanId.CreateRandom();
+        var evt = Some.LogEvent(traceId: traceId, spanId: spanId);
+        var copy = evt.Copy();
+        Assert.Equal(traceId, copy.TraceId);
+        Assert.Equal(spanId, copy.SpanId);
+    }
+}
diff --git a/test/Serilog.Tests/Formatting/Display/MessageTemplateTextFormatterTests.cs b/test/Serilog.Tests/Formatting/Display/MessageTemplateTextFormatterTests.cs
index 6b0268988..6d532523a 100644
--- a/test/Serilog.Tests/Formatting/Display/MessageTemplateTextFormatterTests.cs
+++ b/test/Serilog.Tests/Formatting/Display/MessageTemplateTextFormatterTests.cs
@@ -1,4 +1,8 @@
 
+// ReSharper disable StringLiteralTypo
+
+using System.Diagnostics;
+
 namespace Serilog.Tests.Formatting.Display;
 
 public class MessageTemplateTextFormatterTests
@@ -53,12 +57,12 @@ public void LowercaseFormatSpecifierIsSupportedForStrings()
     [InlineData(Verbose, 6, "Verbos")]
     [InlineData(Verbose, 7, "Verbose")]
     [InlineData(Verbose, 8, "Verbose")]
-    [InlineData(Debug, 1, "D")]
-    [InlineData(Debug, 2, "De")]
-    [InlineData(Debug, 3, "Dbg")]
-    [InlineData(Debug, 4, "Dbug")]
-    [InlineData(Debug, 5, "Debug")]
-    [InlineData(Debug, 6, "Debug")]
+    [InlineData(LogEventLevel.Debug, 1, "D")]
+    [InlineData(LogEventLevel.Debug, 2, "De")]
+    [InlineData(LogEventLevel.Debug, 3, "Dbg")]
+    [InlineData(LogEventLevel.Debug, 4, "Dbug")]
+    [InlineData(LogEventLevel.Debug, 5, "Debug")]
+    [InlineData(LogEventLevel.Debug, 6, "Debug")]
     [InlineData(Information, 1, "I")]
     [InlineData(Information, 2, "In")]
     [InlineData(Information, 3, "Inf")]
@@ -354,4 +358,26 @@ public void PaddingIsApplied(int n, string format, string expected)
         formatter.Format(evt, sw);
         Assert.Equal(expected, sw.ToString());
     }
+
+    [Fact]
+    public void TraceAndSpanAreEmptyWhenAbsent()
+    {
+        var formatter = new MessageTemplateTextFormatter("{TraceId}/{SpanId}", CultureInfo.InvariantCulture);
+        var evt = Some.LogEvent(traceId: default, spanId: default);
+        var sw = new StringWriter();
+        formatter.Format(evt, sw);
+        Assert.Equal("/", sw.ToString());
+    }
+
+    [Fact]
+    public void TraceAndSpanAreIncludedWhenPresent()
+    {
+        var traceId = ActivityTraceId.CreateRandom();
+        var spanId = ActivitySpanId.CreateRandom();
+        var formatter = new MessageTemplateTextFormatter("{TraceId}/{SpanId}", CultureInfo.InvariantCulture);
+        var evt = Some.LogEvent(traceId: traceId, spanId: spanId);
+        var sw = new StringWriter();
+        formatter.Format(evt, sw);
+        Assert.Equal($"{traceId}/{spanId}", sw.ToString());
+    }
 }
diff --git a/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs b/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs
index 006255a34..f0afd5f5b 100644
--- a/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs
+++ b/test/Serilog.Tests/Formatting/Json/JsonFormatterTests.cs
@@ -1,5 +1,6 @@
 using Newtonsoft.Json;
 using System.Collections.ObjectModel;
+using System.Diagnostics;
 
 namespace Serilog.Tests.Formatting.Json;
 
@@ -288,6 +289,32 @@ public void RenderedMessageIsIncludedCorrectlyWhenRequired()
         formatter.Format(e, buffer);
         var json = buffer.ToString();
 
-        Assert.Contains(@",""MessageTemplate"":""value: {AProperty}"",""RenderedMessage"":""value: 12"",", json);
+        Assert.Contains(""","MessageTemplate":"value: {AProperty}","RenderedMessage":"value: 12",""", json);
+    }
+
+    [Fact]
+    public void TraceAndSpanAreIgnoredWhenAbsent()
+    {
+        var evt = Some.LogEvent(traceId: default, spanId: default);
+        var sw = new StringWriter();
+        var formatter = new JsonFormatter();
+        formatter.Format(evt, sw);
+        var formatted = sw.ToString();
+        Assert.DoesNotContain("TraceId", formatted);
+        Assert.DoesNotContain("SpanId", formatted);
+    }
+
+    [Fact]
+    public void TraceAndSpanAreIncludedWhenPresent()
+    {
+        var traceId = ActivityTraceId.CreateRandom();
+        var spanId = ActivitySpanId.CreateRandom();
+        var evt = Some.LogEvent(traceId: traceId, spanId: spanId);
+        var sw = new StringWriter();
+        var formatter = new JsonFormatter();
+        formatter.Format(evt, sw);
+        var formatted = sw.ToString();
+        Assert.Contains($"\"TraceId\":\"{traceId}\"", formatted);
+        Assert.Contains($"\"SpanId\":\"{spanId}\"", formatted);
     }
 }
diff --git a/test/Serilog.Tests/LoggerConfigurationTests.cs b/test/Serilog.Tests/LoggerConfigurationTests.cs
index 7ff386da0..1d020114e 100644
--- a/test/Serilog.Tests/LoggerConfigurationTests.cs
+++ b/test/Serilog.Tests/LoggerConfigurationTests.cs
@@ -602,12 +602,6 @@ public void ExceptionsThrownByDestructuringPoliciesAreNotPropagated()
         Assert.True(true, "No exception reached the caller");
     }
 
-    class ThrowingProperty
-    {
-        // ReSharper disable once UnusedMember.Local
-        public string Property => throw new("Boom!");
-    }
-
     [Fact]
     public void WrappingDecoratesTheConfiguredSink()
     {
diff --git a/test/Serilog.Tests/Support/Some.cs b/test/Serilog.Tests/Support/Some.cs
index ab0edece7..c91ce75df 100644
--- a/test/Serilog.Tests/Support/Some.cs
+++ b/test/Serilog.Tests/Support/Some.cs
@@ -1,3 +1,5 @@
+using System.Diagnostics;
+
 namespace Serilog.Tests.Support;
 
 static class Some
@@ -16,17 +18,25 @@ static class Some
 
     public static DateTimeOffset OffsetInstant() => new(Instant());
 
-    public static LogEvent LogEvent(string sourceContext, DateTimeOffset? timestamp = null, LogEventLevel level = Information)
-    {
-        return new(timestamp ?? OffsetInstant(), level,
-            null, MessageTemplate(),
-            new List<LogEventProperty> { new(Constants.SourceContextPropertyName, new ScalarValue(sourceContext)) });
-    }
-
-    public static LogEvent LogEvent(DateTimeOffset? timestamp = null, LogEventLevel level = Information)
+    public static LogEvent LogEvent(
+        DateTimeOffset? timestamp = null,
+        LogEventLevel level = Information,
+        Exception? exception = null,
+        string? messageTemplate = null,
+        object?[]? propertyValues = null,
+        ActivityTraceId traceId = default,
+        ActivitySpanId spanId = default)
     {
-        return new(timestamp ?? OffsetInstant(), level,
-            null, MessageTemplate(), Enumerable.Empty<LogEventProperty>());
+        var logger = new LoggerConfiguration().CreateLogger();
+        Assert.True(logger.BindMessageTemplate(messageTemplate ?? "DEFAULT TEMPLATE", propertyValues, out var parsedTemplate, out var boundProperties));
+        return new(
+            timestamp ?? OffsetInstant(),
+            level,
+            exception,
+            parsedTemplate,
+            boundProperties,
+            traceId,
+            spanId);
     }
 
     public static LogEvent InformationEvent(DateTimeOffset? timestamp = null)
@@ -36,7 +46,7 @@ public static LogEvent InformationEvent(DateTimeOffset? timestamp = null)
 
     public static LogEvent DebugEvent(DateTimeOffset? timestamp = null)
     {
-        return LogEvent(timestamp, Debug);
+        return LogEvent(timestamp, LogEventLevel.Debug);
     }
 
     public static LogEvent WarningEvent(DateTimeOffset? timestamp = null)