diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs new file mode 100644 index 000000000..f28d64c66 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using LaunchDarkly.Observability.Sampling; +using OpenTelemetry; +using OpenTelemetry.Logs; + +namespace LaunchDarkly.Observability.Otel +{ + /// + /// In dotnet logs cannot be sampled at export time because the log exporter cannot be effectively + /// wrapper. The log exporter is a sealed class, which prevents inheritance, and it also has + /// internal methods which are accessed by other otel components. These internal methods mean + /// that it is not possible to use composition and delegate to the base exporter. + /// + internal class SamplingLogProcessor : BaseProcessor + { + private readonly IExportSampler _sampler; + + public SamplingLogProcessor(IExportSampler sampler) + { + _sampler = sampler; + } + + public override void OnEnd(LogRecord data) + { + var res = _sampler.SampleLog(data); + if (!res.Sample) return; + if (res.Attributes != null && res.Attributes.Count > 0) + { + var combinedAttributes = new List>(res.Attributes); + if (data.Attributes != null) combinedAttributes.AddRange(data.Attributes); + + data.Attributes = combinedAttributes; + } + + base.OnEnd(data); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs new file mode 100644 index 000000000..2abffea0e --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Diagnostics; +using LaunchDarkly.Observability.Sampling; +using OpenTelemetry; +using OpenTelemetry.Exporter; + +namespace LaunchDarkly.Observability.Otel +{ + /// + /// Custom trace exporter that applies sampling before exporting + /// + internal class SamplingTraceExporter : OtlpTraceExporter + { + private readonly IExportSampler _sampler; + + public SamplingTraceExporter(IExportSampler sampler, OtlpExporterOptions options) : base(options) + { + _sampler = sampler; + } + + public override ExportResult Export(in Batch batch) + { + if (!_sampler.IsSamplingEnabled()) return base.Export(batch); + + // Convert batch to enumerable and use the new hierarchical sampling logic + var activities = new List(); + foreach (var activity in batch) activities.Add(activity); + var sampledActivities = SampleSpans.SampleActivities(activities, _sampler); + + if (sampledActivities.Count == 0) + return ExportResult.Success; + + // Create a new batch with only the sampled activities + using (var sampledBatch = new Batch(sampledActivities.ToArray(), sampledActivities.Count)) + { + return base.Export(sampledBatch); + } + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs new file mode 100644 index 000000000..842faaa0e --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; + +namespace LaunchDarkly.Observability.Sampling +{ + /// + /// Utilities for sampling spans including hierarchical span sampling + /// + internal static class SampleSpans + { + /// + /// Sample spans with hierarchical logic that removes children of sampled-out spans + /// + /// Collection of activities to sample + /// The sampler to use for sampling decisions + /// List of sampled activities + public static List SampleActivities(IEnumerable activities, IExportSampler sampler) + { + if (!sampler.IsSamplingEnabled()) return activities.ToList(); + + var omittedSpanIds = new List(); + var activityById = new Dictionary(); + var childrenByParentId = new Dictionary>(); + + // First pass: sample items which are directly impacted by a sampling decision + // and build a map of children spans by parent span id + foreach (var activity in activities) + { + var spanId = activity.SpanId.ToString(); + + // Build parent-child relationship map + if (activity.ParentSpanId != default) + { + var parentSpanId = activity.ParentSpanId.ToString(); + if (!childrenByParentId.ContainsKey(parentSpanId)) + childrenByParentId[parentSpanId] = new List(); + + childrenByParentId[parentSpanId].Add(spanId); + } + + // Sample the span + var sampleResult = sampler.SampleSpan(activity); + if (sampleResult.Sample) + { + if (sampleResult.Attributes != null && sampleResult.Attributes.Count > 0) + foreach (var attr in sampleResult.Attributes) + activity.SetTag(attr.Key, attr.Value); + + activityById[spanId] = activity; + } + else + { + omittedSpanIds.Add(spanId); + } + } + + // Find all children of spans that have been sampled out and remove them + // Repeat until there are no more children to remove + while (omittedSpanIds.Count > 0) + { + var spanId = omittedSpanIds[0]; + omittedSpanIds.RemoveAt(0); + + if (!childrenByParentId.TryGetValue(spanId, out var affectedSpans)) continue; + foreach (var spanIdToRemove in affectedSpans) + { + activityById.Remove(spanIdToRemove); + omittedSpanIds.Add(spanIdToRemove); + } + } + + return activityById.Values.ToList(); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs index 6aa11999d..628a012fc 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs @@ -302,17 +302,6 @@ public void LogSamplingTests(object oScenario) sampler.SetConfig(scenario.SamplingConfig); Assert.That(sampler.IsSamplingEnabled(), Is.True); - var services = new ServiceCollection(); - var records = new List(); - services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); }, - options => { options.IncludeScopes = true; }); - Console.WriteLine(services); - var provider = services.BuildServiceProvider(); - var loggerProvider = provider.GetService(); - var withScope = loggerProvider as ISupportExternalScope; - Assert.That(withScope, Is.Not.Null); - withScope.SetScopeProvider(new LoggerExternalScopeProvider()); - var logger = loggerProvider.CreateLogger("test"); var properties = new Dictionary(); foreach (var inputLogAttribute in scenario.InputLog.Attributes) @@ -320,21 +309,13 @@ public void LogSamplingTests(object oScenario) properties.Add(inputLogAttribute.Key, GetJsonRawValue(inputLogAttribute)); } - using (logger.BeginScope(properties)) - { - logger.Log(SeverityTextToLogLevel(scenario.InputLog.SeverityText), - new EventId(), properties, null, - (objects, exception) => scenario.InputLog.Message ?? ""); - } - - Console.WriteLine(records); - - var record = records.First(); + var record = LogRecordHelper.CreateTestLogRecord(SeverityTextToLogLevel(scenario.InputLog.SeverityText), + scenario.InputLog.Message ?? "", properties); Assert.Multiple(() => { // Cursory check that the record is formed properly. Assert.That(scenario.InputLog.Message ?? "", Is.EqualTo(record.Body)); - Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count)); + Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count ?? 0)); }); var res = sampler.SampleLog(record); diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs new file mode 100644 index 000000000..254374856 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using OpenTelemetry.Logs; + +namespace LaunchDarkly.Observability.Test +{ + public static class LogRecordHelper + { + /// + /// Creates a LogRecord for testing + /// + public static LogRecord CreateTestLogRecord(LogLevel level, string message, + Dictionary attributes = null) + { + var services = new ServiceCollection(); + var records = new List(); + + services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); }); + + var provider = services.BuildServiceProvider(); + var loggerProvider = provider.GetService(); + var withScope = loggerProvider as ISupportExternalScope; + Assert.That(withScope, Is.Not.Null); + withScope.SetScopeProvider(new LoggerExternalScopeProvider()); + var logger = loggerProvider.CreateLogger("test"); + + // Log with attributes if provided - use the same pattern as CustomSamplerTests + if (attributes != null && attributes.Count > 0) + logger.Log(level, new EventId(), attributes, null, + (objects, exception) => message); + else + logger.Log(level, new EventId(), null, null, + (objects, exception) => message); + + return records.First(); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs new file mode 100644 index 000000000..7d8154eea --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs @@ -0,0 +1,133 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using LaunchDarkly.Observability.Sampling; +using NUnit.Framework; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class SampleSpansTests + { + [Test] + public void SampleActivities_ShouldRemoveSpansThatAreNotSampled() + { + var activities = new List + { + TestActivityHelper.CreateTestActivity("span-1"), // Root span - sampled + TestActivityHelper.CreateTestActivity("span-2") // Root span - not sampled + }; + + var span1 = activities[0]; + var span2 = activities[1]; + + // We need to mock the span IDs in our sampler + var samplerWithRealIds = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [span1.SpanId.ToString()] = true, + [span2.SpanId.ToString()] = false + }, + attributesToAdd: new Dictionary { ["samplingRatio"] = 2 } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, samplerWithRealIds); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(1)); + Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("span-1")); + Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)), + Is.True); + } + + [Test] + public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled() + { + // Arrange - Create span hierarchy with parent -> child -> grandchild + var parentActivity = TestActivityHelper.CreateTestActivity("parent"); + var childActivity = TestActivityHelper.CreateTestActivity("child", parentActivity.SpanId.ToString()); + var grandchildActivity = + TestActivityHelper.CreateTestActivity("grandchild", childActivity.SpanId.ToString()); + var rootActivity = TestActivityHelper.CreateTestActivity("root"); + + var activities = new List + { + parentActivity, + childActivity, + grandchildActivity, + rootActivity + }; + + var mockSampler = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [parentActivity.SpanId.ToString()] = false, // Parent not sampled + [childActivity.SpanId.ToString()] = true, // Child would be sampled but parent isn't + [grandchildActivity.SpanId.ToString()] = true, // Grandchild would be sampled but parent isn't + [rootActivity.SpanId.ToString()] = true // Root sampled + } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(1)); + Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("root")); + } + + [Test] + public void SampleActivities_ShouldNotApplySamplingWhenSamplingIsDisabled() + { + // Arrange + var mockSampler = TestSamplerHelper.CreateMockSampler( + new Dictionary(), // Empty results + enabled: false // Sampling disabled + ); + + var activities = new List + { + TestActivityHelper.CreateTestActivity("span-1"), + TestActivityHelper.CreateTestActivity("span-2") + }; + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(2)); + Assert.That(sampledActivities, Is.EqualTo(activities)); + } + + [Test] + public void SampleActivities_ShouldApplySamplingAttributesToSampledSpans() + { + // Arrange + var activities = new List + { + TestActivityHelper.CreateTestActivity("span-1"), + TestActivityHelper.CreateTestActivity("span-2") + }; + + var mockSampler = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [activities[0].SpanId.ToString()] = true, + [activities[1].SpanId.ToString()] = true + }, + attributesToAdd: new Dictionary { ["samplingRatio"] = 2 } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(2)); + Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)), + Is.True); + Assert.That(sampledActivities[1].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)), + Is.True); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs new file mode 100644 index 000000000..bec1620ee --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Observability.Otel; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class SamplingLogProcessorTests + { + [Test] + public void OnEnd_WhenSamplerReturnsFalse_ShouldNotModifyAttributes() + { + var sampler = TestSamplerHelper.CreateMockSampler(shouldSampleLogs: false); + var processor = new SamplingLogProcessor(sampler); + var logRecord = LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message"); + var originalAttributes = logRecord.Attributes?.ToList(); + + processor.OnEnd(logRecord); + + var finalAttributes = logRecord.Attributes?.ToList(); + + if (originalAttributes == null) + Assert.That(finalAttributes, Is.Null.Or.Empty); + else + Assert.That(finalAttributes?.Count, Is.EqualTo(originalAttributes.Count)); + } + + [Test] + public void OnEnd_WhenSamplerReturnsTrue_WithNoAttributes_ShouldNotModifyRecord() + { + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: new Dictionary() + ); + var processor = new SamplingLogProcessor(sampler); + var logRecord = LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message"); + var originalAttributes = logRecord.Attributes?.ToList(); + + processor.OnEnd(logRecord); + + var finalAttributes = logRecord.Attributes?.ToList(); + + if (originalAttributes == null) + Assert.That(finalAttributes, Is.Null.Or.Empty); + else + Assert.That(finalAttributes?.Count, Is.EqualTo(originalAttributes.Count)); + } + + [Test] + public void OnEnd_WhenSamplerAddsAttributes_ShouldMergeWithExistingAttributes() + { + var samplingAttributes = new Dictionary + { + ["sampling.ratio"] = 0.5, + ["sampler.type"] = "custom" + }; + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: samplingAttributes + ); + var processor = new SamplingLogProcessor(sampler); + + var existingAttributes = new Dictionary + { + ["existing.key"] = "existing.value", + ["another.key"] = 42 + }; + var logRecord = LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message with attributes", + existingAttributes); + + processor.OnEnd(logRecord); + + Assert.That(logRecord.Attributes, Is.Not.Null); + + var attributesList = logRecord.Attributes.ToList(); + + Assert.Multiple(() => + { + // Check that sampling attributes were added + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "sampling.ratio" && kvp.Value.Equals(0.5); + }), Is.True); + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "sampler.type" && kvp.Value.Equals("custom"); + }), Is.True); + + // Check that existing attributes are preserved + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "existing.key" && kvp.Value.Equals("existing.value"); + }), + Is.True); + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "another.key" && kvp.Value.Equals(42); + }), Is.True); + }); + } + + [Test] + public void OnEnd_WhenLogHasNoExistingAttributes_ShouldAddSamplingAttributes() + { + var samplingAttributes = new Dictionary + { + ["sampling.ratio"] = 1.0, + ["sampler.enabled"] = true + }; + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: samplingAttributes + ); + var processor = new SamplingLogProcessor(sampler); + var logRecord = + LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message without attributes"); + + processor.OnEnd(logRecord); + + Assert.That(logRecord.Attributes, Is.Not.Null); + + var attributesList = logRecord.Attributes.ToList(); + + Assert.Multiple(() => + { + // Check that sampling attributes were added + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "sampling.ratio" && kvp.Value.Equals(1.0); + }), Is.True); + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "sampler.enabled" && kvp.Value.Equals(true); + }), Is.True); + }); + } + + [Test] + public void OnEnd_WhenSamplingRatioAttributeIsAdded_ShouldBeIncludedInLogAttributes() + { + var samplingAttributes = new Dictionary + { + ["sampling.ratio"] = 0.25 + }; + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: samplingAttributes + ); + var processor = new SamplingLogProcessor(sampler); + var logRecord = + LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message for sampling ratio"); + + processor.OnEnd(logRecord); + + Assert.That(logRecord.Attributes, Is.Not.Null); + + var attributesList = logRecord.Attributes.ToList(); + Assert.That(attributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "sampling.ratio" && kvp.Value.Equals(0.25); + }), Is.True); + } + + [Test] + public void OnEnd_WhenSamplerHasEmptyAttributes_ShouldNotAddAttributes() + { + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: new Dictionary() + ); + var processor = new SamplingLogProcessor(sampler); + + var existingAttributes = new Dictionary + { + ["original.key"] = "original.value" + }; + var logRecord = + LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message", existingAttributes); + var originalAttributesList = logRecord.Attributes?.ToList() ?? new List>(); + + processor.OnEnd(logRecord); + + var finalAttributesList = logRecord.Attributes?.ToList() ?? new List>(); + Assert.That(finalAttributesList, Has.Count.EqualTo(originalAttributesList.Count)); + Assert.That(finalAttributesList.Any(kvp => + { + Assert.That(kvp.Value, Is.Not.Null); + return kvp.Key == "original.key" && kvp.Value.Equals("original.value"); + }), + Is.True); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs new file mode 100644 index 000000000..04a6b49f5 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using LaunchDarkly.Observability.Sampling; +using NUnit.Framework; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class SamplingTraceExporterTests + { + [Test] + public void SampleActivities_WhenSamplingDisabled_ShouldReturnAllActivities() + { + // Arrange + var sampler = TestSamplerHelper.CreateMockSampler( + new Dictionary(), + enabled: false + ); + + var activities = new[] + { + TestActivityHelper.CreateTestActivity("span1"), + TestActivityHelper.CreateTestActivity("span2") + }; + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, sampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(2)); + Assert.That(sampledActivities, Is.EqualTo(activities)); + } + + [Test] + public void SampleActivities_WhenSpansAreFilteredOut_ShouldReturnOnlySelectedSpans() + { + // Arrange + var activities = new[] + { + TestActivityHelper.CreateTestActivity("span1"), + TestActivityHelper.CreateTestActivity("span2"), + TestActivityHelper.CreateTestActivity("span3") + }; + + var sampler = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [activities[0].SpanId.ToString()] = true, // Include + [activities[1].SpanId.ToString()] = false, // Exclude + [activities[2].SpanId.ToString()] = true // Include + }, + attributesToAdd: new Dictionary { ["test.sampled"] = true } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, sampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(2)); + Assert.That(sampledActivities.Any(a => a.DisplayName == "span1"), Is.True); + Assert.That(sampledActivities.Any(a => a.DisplayName == "span3"), Is.True); + Assert.That(sampledActivities.Any(a => a.DisplayName == "span2"), Is.False); + } + + [Test] + public void SampleActivities_WhenParentSpanIsFilteredOut_ShouldAlsoFilterOutChildren() + { + // Arrange + var parentActivity = TestActivityHelper.CreateTestActivity("parent"); + var childActivity = TestActivityHelper.CreateTestActivity("child", parentActivity.SpanId.ToString()); + var independentActivity = TestActivityHelper.CreateTestActivity("independent"); + + var activities = new[] { parentActivity, childActivity, independentActivity }; + + var sampler = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [parentActivity.SpanId.ToString()] = false, // Parent filtered out + [childActivity.SpanId.ToString()] = true, // Child would be included but parent isn't + [independentActivity.SpanId.ToString()] = true // Independent span included + }, + attributesToAdd: new Dictionary { ["test.sampled"] = true } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, sampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(1)); + Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("independent")); + } + + [Test] + public void SampleActivities_WhenNoActivitiesPassSampling_ShouldReturnEmptyList() + { + // Arrange + var activities = new[] + { + TestActivityHelper.CreateTestActivity("span1"), + TestActivityHelper.CreateTestActivity("span2") + }; + + var sampler = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [activities[0].SpanId.ToString()] = false, + [activities[1].SpanId.ToString()] = false + } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, sampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(0)); + } + + [Test] + public void SampleActivities_WhenSamplingAttributesAreAdded_ShouldIncludeThemInActivities() + { + // Arrange + var activities = new[] + { + TestActivityHelper.CreateTestActivity("span1") + }; + + var sampler = TestSamplerHelper.CreateMockSampler( + new Dictionary + { + [activities[0].SpanId.ToString()] = true + }, + attributesToAdd: new Dictionary { ["test.sampled"] = true } + ); + + // Act + var sampledActivities = SampleSpans.SampleActivities(activities, sampler); + + // Assert + Assert.That(sampledActivities.Count, Is.EqualTo(1)); + Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "test.sampled" && t.Value.Equals(true)), + Is.True); + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestActivityHelper.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestActivityHelper.cs new file mode 100644 index 000000000..2786ce20a --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestActivityHelper.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; + +namespace LaunchDarkly.Observability.Test +{ + /// + /// Helper class for creating test activities + /// + internal static class TestActivityHelper + { + /// + /// Creates a test Activity with the specified name and optional parent span ID + /// + /// The name/display name for the activity + /// Optional parent span ID to create a child relationship + /// A stopped Activity ready for testing + internal static Activity CreateTestActivity(string name, string parentSpanId = null) + { + var activity = new Activity(name); + activity.Start(); + activity.DisplayName = name; + + // Set W3C format for consistent trace/span ID generation + activity.SetIdFormat(ActivityIdFormat.W3C); + + // If we have a parent span ID, we need to create a proper parent context + if (!string.IsNullOrEmpty(parentSpanId)) + { + // Create a trace ID and parent span context + var traceId = ActivityTraceId.CreateRandom(); + var parentSpan = ActivitySpanId.CreateFromString(parentSpanId.PadRight(16, '0')); + var parentContext = new ActivityContext(traceId, parentSpan, ActivityTraceFlags.Recorded); + + // Stop and recreate the activity with the parent context + activity.Stop(); + activity = new Activity(name); + activity.SetParentId(parentContext.TraceId, parentContext.SpanId, parentContext.TraceFlags); + activity.Start(); + activity.DisplayName = name; + } + + activity.Stop(); + return activity; + } + } +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs new file mode 100644 index 000000000..7ca0b56e2 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; +using System.Diagnostics; +using LaunchDarkly.Observability.Sampling; +using OpenTelemetry.Logs; + +namespace LaunchDarkly.Observability.Test +{ + /// + /// Helper class providing test samplers for unit tests + /// + internal static class TestSamplerHelper + { + /// + /// Creates a mock sampler that can be configured with specific sampling results for spans and logs + /// + internal static IExportSampler CreateMockSampler( + Dictionary spanSampleResults = null, + bool shouldSampleLogs = true, + Dictionary attributesToAdd = null, + bool enabled = true) + { + return new MockSampler(spanSampleResults, shouldSampleLogs, attributesToAdd, enabled); + } + + /// + /// Mock implementation of IExportSampler for testing + /// + private class MockSampler : IExportSampler + { + private readonly Dictionary _attributesToAdd; + private readonly bool _enabled; + private readonly bool _shouldSampleLogs; + private readonly Dictionary _spanSampleResults; + + public MockSampler( + Dictionary spanSampleResults, + bool shouldSampleLogs, + Dictionary attributesToAdd, + bool enabled) + { + _spanSampleResults = spanSampleResults ?? new Dictionary(); + _shouldSampleLogs = shouldSampleLogs; + _attributesToAdd = attributesToAdd ?? new Dictionary(); + _enabled = enabled; + } + + public void SetConfig(SamplingConfig config) + { + // Not needed for tests + } + + public SamplingResult SampleSpan(Activity span) + { + var spanId = span.SpanId.ToString(); + var shouldSample = _spanSampleResults.GetValueOrDefault(spanId, true); + + return new SamplingResult + { + Sample = shouldSample, + Attributes = shouldSample ? _attributesToAdd : new Dictionary() + }; + } + + public SamplingResult SampleLog(LogRecord record) + { + return new SamplingResult + { + Sample = _shouldSampleLogs, + Attributes = _shouldSampleLogs ? _attributesToAdd : new Dictionary() + }; + } + + public bool IsSamplingEnabled() + { + return _enabled; + } + } + } +}