diff --git a/test/Serilog.Sinks.OpenTelemetry.Tests/OpenTelemetryTracesSinkTests.cs b/test/Serilog.Sinks.OpenTelemetry.Tests/OpenTelemetryTracesSinkTests.cs new file mode 100644 index 0000000..a4f287d --- /dev/null +++ b/test/Serilog.Sinks.OpenTelemetry.Tests/OpenTelemetryTracesSinkTests.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; +using OpenTelemetry.Proto.Collector.Trace.V1; +using Serilog.Events; +using Serilog.Sinks.OpenTelemetry.ProtocolHelpers; +using Serilog.Sinks.OpenTelemetry.Tests.Support; +using Xunit; + +namespace Serilog.Sinks.OpenTelemetry.Tests; + +public class OpenTelemetryTracesSinkTests +{ + [Fact] + public async Task SpanCarriesExpectedSimpleProperties() + { + var start = Some.UtcDateTime(); + var parent = ActivitySpanId.CreateRandom(); + var kind = ActivityKind.Consumer; + using var activity = Some.Activity(); + var events = CollectingSink.Collect(log => ForSpan(log, start, kind, parent).Information("Hello, {Name}!", "World")); + var request = await ExportAsync(events); + var resourceSpans = Assert.Single(request.ResourceSpans); + var scopeSpans = Assert.Single(resourceSpans.ScopeSpans); + var span = Assert.Single(scopeSpans.Spans); + Assert.Equal("Hello, {Name}!", span.Name); + Assert.Equal(PrimitiveConversions.ToUnixNano(start), span.StartTimeUnixNano); + Assert.Equal(PrimitiveConversions.ToUnixNano(events.Single().Timestamp), span.EndTimeUnixNano); + Assert.Equal(PrimitiveConversions.ToOpenTelemetrySpanId(parent.ToString()), span.ParentSpanId); + Assert.Equal(PrimitiveConversions.ToOpenTelemetrySpanKind(kind), span.Kind); + Assert.Contains(span.Attributes, kv => kv.Key == "Name" && kv.Value.StringValue == "World"); + } + + [Fact] + public async Task DefaultScopeIsNull() + { + using var activity = Some.Activity(); + var events = CollectingSink.Collect(log => ForSpan(log).Information("Hello, world!")); + var request = await ExportAsync(events); + var resourceSpans = Assert.Single(request.ResourceSpans); + var scopeSpans = Assert.Single(resourceSpans.ScopeSpans); + Assert.Null(scopeSpans.Scope); + } + + [Fact] + public async Task SourceContextNameIsInstrumentationScope() + { + using var activity = Some.Activity(); + var contextType = typeof(OtlpEventBuilderTests); + var events = CollectingSink.Collect(log => ForSpan(log).ForContext(contextType).Information("Hello, world!")); + var request = await ExportAsync(events); + var resourceSpans = Assert.Single(request.ResourceSpans); + var scopeSpans = Assert.Single(resourceSpans.ScopeSpans); + Assert.Equal(contextType.FullName, scopeSpans.Scope.Name); + } + + [Fact] + public async Task ScopeSpansAreGrouped() + { + using var activity = Some.Activity(); + var events = CollectingSink.Collect(log => + { + ForSpan(log).ForContext(Core.Constants.SourceContextPropertyName, "A").Information("Hello, world!"); + ForSpan(log).ForContext(Core.Constants.SourceContextPropertyName, "B").Information("Hello, world!"); + ForSpan(log).ForContext(Core.Constants.SourceContextPropertyName, "A").Information("Hello, world!"); + ForSpan(log).Information("Hello, world!"); + }); + var request = await ExportAsync(events); + var resourceSpans = Assert.Single(request.ResourceSpans); + Assert.Equal(3, resourceSpans.ScopeSpans.Count); + Assert.Equal(4, resourceSpans.ScopeSpans.SelectMany(s => s.Spans).Count()); + Assert.Equal(2, resourceSpans.ScopeSpans.Single(r => r.Scope?.Name == "A").Spans.Count); + Assert.Single(resourceSpans.ScopeSpans.Single(r => r.Scope?.Name == "B").Spans); + Assert.Single(resourceSpans.ScopeSpans.Single(r => r.Scope == null).Spans); + } + + static async Task ExportAsync(IReadOnlyCollection events) + { + var exporter = new CollectingExporter(); + var sink = new OpenTelemetryTracesSink(exporter, new Dictionary(), OpenTelemetrySinkOptions.DefaultIncludedData); + await sink.EmitBatchAsync(events); + return Assert.Single(exporter.ExportTraceServiceRequests); + } + + // Produces a completely imaginary span, which will be inconsistent with Activity.Current except to carry the + // same span id. + static ILogger ForSpan(ILogger logger, DateTime? start = null, ActivityKind kind = ActivityKind.Internal, ActivitySpanId? parentId = null) + { + var result = logger.ForContext("SpanStartTimestamp", start ?? Some.UtcDateTime()) + .ForContext("SpanKind", kind); + + if (parentId != null) + result = result.ForContext("ParentSpanId", parentId.Value); + + return result; + } +} diff --git a/test/Serilog.Sinks.OpenTelemetry.Tests/Support/CollectingSink.cs b/test/Serilog.Sinks.OpenTelemetry.Tests/Support/CollectingSink.cs index a2692bb..89bdde5 100644 --- a/test/Serilog.Sinks.OpenTelemetry.Tests/Support/CollectingSink.cs +++ b/test/Serilog.Sinks.OpenTelemetry.Tests/Support/CollectingSink.cs @@ -1,4 +1,5 @@ -using Serilog.Core; +using System.Diagnostics; +using Serilog.Core; using Serilog.Events; using Xunit; @@ -23,6 +24,8 @@ public static IReadOnlyList Collect(Action emitter) var sink = new CollectingSink(); var collector = new LoggerConfiguration() .WriteTo.Sink(sink) + .Destructure.AsScalar() + .Destructure.AsScalar() .CreateLogger(); emitter(collector); diff --git a/test/Serilog.Sinks.OpenTelemetry.Tests/Support/Some.cs b/test/Serilog.Sinks.OpenTelemetry.Tests/Support/Some.cs index 0eb46a4..39647f3 100644 --- a/test/Serilog.Sinks.OpenTelemetry.Tests/Support/Some.cs +++ b/test/Serilog.Sinks.OpenTelemetry.Tests/Support/Some.cs @@ -12,8 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Diagnostics; using Serilog.Events; using Serilog.Parsing; +// ReSharper disable UnusedMember.Global namespace Serilog.Sinks.OpenTelemetry.Tests.Support; @@ -61,4 +63,35 @@ public static string String() { return $"S_{Int32()}"; } + + public static DateTime UtcDateTime() + { + return DateTime.UtcNow; + } + + public sealed class TestActivity(ActivityListener listener, ActivitySource source, Activity activity) : IDisposable + { + public Activity Activity => activity; + + public void Dispose() + { + activity.Dispose(); + source.Dispose(); + listener.Dispose(); + } + } + + public static TestActivity Activity() + { + using var listener = new ActivityListener(); + listener.ShouldListenTo = _ => true; + listener.Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData; + ActivitySource.AddActivityListener(listener); + + var source = new ActivitySource(String(), "1.0.0"); + + var activity = source.StartActivity(); + + return new TestActivity(listener, source, activity!); + } }