diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/TraceContextEnricher.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/TraceContextEnricher.cs new file mode 100644 index 0000000..f2b88cd --- /dev/null +++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/TraceContextEnricher.cs @@ -0,0 +1,43 @@ +using System.Diagnostics; +using Serilog.Core; +using Serilog.Events; + +namespace ZB.MOM.WW.Telemetry.Serilog; + +/// +/// Stamps trace_id and span_id from onto every Serilog +/// log event, enabling a log line to be correlated back to its originating trace in a backend. +/// When is null (no active span — background services, startup, +/// non-traced paths) the enricher emits nothing; it does NOT inject empty strings or zero values. +/// +public sealed class TraceContextEnricher : ILogEventEnricher +{ + /// Serilog property name carrying the W3C trace id. + public const string TraceIdPropertyName = "trace_id"; + + /// Serilog property name carrying the W3C span id. + public const string SpanIdPropertyName = "span_id"; + + /// + /// Adds trace_id/span_id properties from when an + /// activity is active; otherwise leaves the event untouched. + /// + /// The log event to enrich. + /// Factory used to create the trace-context properties. + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(propertyFactory); + + var activity = Activity.Current; + if (activity is null) + { + return; + } + + logEvent.AddPropertyIfAbsent( + propertyFactory.CreateProperty(TraceIdPropertyName, activity.TraceId.ToString())); + logEvent.AddPropertyIfAbsent( + propertyFactory.CreateProperty(SpanIdPropertyName, activity.SpanId.ToString())); + } +} diff --git a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs index 416d27d..927fe58 100644 --- a/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs +++ b/ZB.MOM.WW.Telemetry/src/ZB.MOM.WW.Telemetry.Serilog/ZbSerilogConfig.cs @@ -64,6 +64,8 @@ public static class ZbSerilogConfig enrich.WithProperty(ZbLogEnricherNames.NodeHostname, Environment.MachineName); + enrich.With(new TraceContextEnricher()); + return loggerConfiguration; } } diff --git a/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/TraceContextEnricherTests.cs b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/TraceContextEnricherTests.cs new file mode 100644 index 0000000..e6d88c5 --- /dev/null +++ b/ZB.MOM.WW.Telemetry/tests/ZB.MOM.WW.Telemetry.Serilog.Tests/TraceContextEnricherTests.cs @@ -0,0 +1,66 @@ +using System.Diagnostics; +using Serilog; +using Serilog.Core; +using Serilog.Events; +using Serilog.Sinks.InMemory; +using ZB.MOM.WW.Telemetry; +using ZB.MOM.WW.Telemetry.Serilog; + +namespace ZB.MOM.WW.Telemetry.Serilog.Tests; + +public sealed class TraceContextEnricherTests +{ + private const string SourceName = "ZB.MOM.WW.Telemetry.Serilog.Tests.TraceContext"; + + private static Logger BuildLogger(InMemorySink sink) => + new LoggerConfiguration() + .Enrich.With(new TraceContextEnricher()) + .WriteTo.Sink(sink) + .CreateLogger(); + + private static string? ScalarOrNull(LogEvent logEvent, string propertyName) => + logEvent.Properties.TryGetValue(propertyName, out var value) && value is ScalarValue scalar + ? scalar.Value?.ToString() + : null; + + [Fact] + public void Active_activity_stamps_trace_id_and_span_id() + { + using var listener = new ActivityListener + { + ShouldListenTo = source => source.Name == SourceName, + Sample = (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(listener); + + using var activitySource = new ActivitySource(SourceName); + var sink = new InMemorySink(); + using var logger = BuildLogger(sink); + + using var activity = activitySource.StartActivity("unit-test"); + Assert.NotNull(activity); + Assert.NotNull(Activity.Current); + + logger.Information("traced"); + + var logEvent = Assert.Single(sink.LogEvents); + Assert.Equal(Activity.Current!.TraceId.ToString(), ScalarOrNull(logEvent, "trace_id")); + Assert.Equal(Activity.Current!.SpanId.ToString(), ScalarOrNull(logEvent, "span_id")); + } + + [Fact] + public void No_active_activity_omits_trace_id_and_span_id() + { + Assert.Null(Activity.Current); + + var sink = new InMemorySink(); + using var logger = BuildLogger(sink); + + logger.Information("untraced"); + + var logEvent = Assert.Single(sink.LogEvents); + Assert.False(logEvent.Properties.ContainsKey("trace_id")); + Assert.False(logEvent.Properties.ContainsKey("span_id")); + } +}