From acdc4dc5a007fb0abf7cddfbc945ef9bd1df16b0 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:43:45 -0700 Subject: [PATCH 1/7] Baseline tests are working. --- .../Otel/SamplingLogProcessor.cs | 39 +++ .../Otel/SamplingTraceExporter.cs | 46 ++++ .../Sampling/SampleSpans.cs | 85 ++++++ .../CustomSamplerTests.cs | 25 +- .../LogRecordHelper.cs | 45 ++++ .../SampleSpansTests.cs | 206 +++++++++++++++ .../SamplingLogProcessorTests.cs | 249 ++++++++++++++++++ .../SamplingTraceExporterTests.cs | 201 ++++++++++++++ 8 files changed, 875 insertions(+), 21 deletions(-) create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs 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 0000000000..26c75a5156 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs @@ -0,0 +1,39 @@ +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 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 0000000000..d11ed8d9e7 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs @@ -0,0 +1,46 @@ +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 0000000000..c83555f1f3 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs @@ -0,0 +1,85 @@ +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 6aa11999df..4559e6f18e 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,15 @@ 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 = 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 0000000000..697795abfd --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs @@ -0,0 +1,45 @@ +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 0000000000..64da1a95a8 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using LaunchDarkly.Observability.Sampling; +using NUnit.Framework; +using OpenTelemetry.Logs; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class SampleSpansTests + { + #region Helper Classes and Methods + + /// + /// Mock implementation of IExportSampler for testing + /// + private class MockSampler : IExportSampler + { + private readonly Dictionary _mockResults; + private readonly bool _enabled; + + public MockSampler(Dictionary mockResults, bool enabled = true) + { + _mockResults = mockResults; + _enabled = enabled; + } + + public void SetConfig(SamplingConfig config) + { + // Not needed for tests + } + + public SamplingResult SampleSpan(Activity span) + { + var spanId = span.SpanId.ToString(); + var shouldSample = _mockResults.GetValueOrDefault(spanId, true); + + return new SamplingResult + { + Sample = shouldSample, + Attributes = shouldSample + ? new Dictionary { ["samplingRatio"] = 2 } + : new Dictionary() + }; + } + + public SamplingResult SampleLog(LogRecord record) + { + return new SamplingResult { Sample = true }; + } + + public bool IsSamplingEnabled() + { + return _enabled; + } + } + + /// + /// Helper method to create a mock Activity + /// + private static Activity CreateMockActivity(string name, string parentSpanId = null) + { + var activity = new Activity(name); + activity.Start(); + activity.DisplayName = name; + + // Set a custom span ID for predictable testing + 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; + } + + #endregion + + [Test] + public void SampleActivities_ShouldRemoveSpansThatAreNotSampled() + { + var activities = new List + { + CreateMockActivity("span-1"), // Root span - sampled + CreateMockActivity("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 = new MockSampler(new Dictionary + { + [span1.SpanId.ToString()] = true, + [span2.SpanId.ToString()] = false + }); + + // 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 = CreateMockActivity("parent"); + var childActivity = CreateMockActivity("child", parentActivity.SpanId.ToString()); + var grandchildActivity = CreateMockActivity("grandchild", childActivity.SpanId.ToString()); + var rootActivity = CreateMockActivity("root"); + + var activities = new List + { + parentActivity, + childActivity, + grandchildActivity, + rootActivity + }; + + var mockSampler = new MockSampler(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 = new MockSampler( + new Dictionary(), // Empty results + enabled: false // Sampling disabled + ); + + var activities = new List + { + CreateMockActivity("span-1"), + CreateMockActivity("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 + { + CreateMockActivity("span-1"), + CreateMockActivity("span-2") + }; + + var mockSampler = new MockSampler(new Dictionary + { + [activities[0].SpanId.ToString()] = true, + [activities[1].SpanId.ToString()] = true + }); + + // 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 0000000000..defad12205 --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs @@ -0,0 +1,249 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using LaunchDarkly.Observability.Otel; +using LaunchDarkly.Observability.Sampling; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using OpenTelemetry.Logs; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class SamplingLogProcessorTests + { + #region Helper Classes + + /// + /// Mock sampler for testing SamplingLogProcessor + /// + private class MockLogSampler : IExportSampler + { + private readonly bool _shouldSample; + private readonly Dictionary _attributesToAdd; + private readonly bool _enabled; + + public MockLogSampler(bool shouldSample, Dictionary attributesToAdd = null, + bool enabled = true) + { + _shouldSample = shouldSample; + _attributesToAdd = attributesToAdd ?? new Dictionary(); + _enabled = enabled; + } + + public SamplingResult SampleLog(LogRecord record) + { + return new SamplingResult + { + Sample = _shouldSample, + Attributes = _attributesToAdd + }; + } + + public SamplingResult SampleSpan(Activity span) + { + return new SamplingResult { Sample = true }; + } + + public bool IsSamplingEnabled() + { + return _enabled; + } + + public void SetConfig(SamplingConfig config) + { + // No-op for tests + } + } + + #endregion + + + #region Tests + + [Test] + public void OnEnd_WhenSamplerReturnsFalse_ShouldNotModifyAttributes() + { + var sampler = new MockLogSampler(shouldSample: 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 = new MockLogSampler(shouldSample: 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 = new MockLogSampler(shouldSample: 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 = new MockLogSampler(shouldSample: 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 = new MockLogSampler(shouldSample: 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 = new MockLogSampler(shouldSample: 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); + } + + #endregion + } +} 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 0000000000..cddf80115f --- /dev/null +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using LaunchDarkly.Observability.Sampling; +using NUnit.Framework; +using OpenTelemetry.Logs; + +namespace LaunchDarkly.Observability.Test +{ + [TestFixture] + public class SamplingTraceExporterTests + { + #region Helper Classes + + /// + /// Mock sampler for testing + /// + private class MockSampler : IExportSampler + { + private readonly Dictionary _sampleResults; + private readonly bool _enabled; + + public MockSampler(Dictionary sampleResults, bool enabled = true) + { + _sampleResults = sampleResults; + _enabled = enabled; + } + + public void SetConfig(SamplingConfig config) { } + + public SamplingResult SampleSpan(Activity span) + { + var spanId = span.SpanId.ToString(); + var shouldSample = _sampleResults.ContainsKey(spanId) ? _sampleResults[spanId] : true; + + return new SamplingResult + { + Sample = shouldSample, + Attributes = shouldSample ? new Dictionary { ["test.sampled"] = true } : new Dictionary() + }; + } + + public SamplingResult SampleLog(LogRecord record) + { + return new SamplingResult { Sample = true }; + } + + public bool IsSamplingEnabled() + { + return _enabled; + } + } + + /// + /// Helper to create test activities + /// + private static Activity CreateTestActivity(string name, string parentSpanId = null) + { + var activity = new Activity(name); + activity.Start(); + activity.DisplayName = name; + + if (!string.IsNullOrEmpty(parentSpanId)) + { + var traceId = ActivityTraceId.CreateRandom(); + var parentSpan = ActivitySpanId.CreateFromString(parentSpanId.PadRight(16, '0')); + var parentContext = new ActivityContext(traceId, parentSpan, ActivityTraceFlags.Recorded); + + activity.Stop(); + activity = new Activity(name); + activity.SetParentId(parentContext.TraceId, parentContext.SpanId, parentContext.TraceFlags); + activity.Start(); + activity.DisplayName = name; + } + + activity.Stop(); + return activity; + } + + #endregion + + [Test] + public void SampleActivities_WhenSamplingDisabled_ShouldReturnAllActivities() + { + // Arrange + var sampler = new MockSampler(new Dictionary(), enabled: false); + + var activities = new[] + { + CreateTestActivity("span1"), + 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[] + { + CreateTestActivity("span1"), + CreateTestActivity("span2"), + CreateTestActivity("span3") + }; + + var sampler = new MockSampler(new Dictionary + { + [activities[0].SpanId.ToString()] = true, // Include + [activities[1].SpanId.ToString()] = false, // Exclude + [activities[2].SpanId.ToString()] = true // Include + }); + + // 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 = CreateTestActivity("parent"); + var childActivity = CreateTestActivity("child", parentActivity.SpanId.ToString()); + var independentActivity = CreateTestActivity("independent"); + + var activities = new[] { parentActivity, childActivity, independentActivity }; + + var sampler = new MockSampler(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 + }); + + // 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[] + { + CreateTestActivity("span1"), + CreateTestActivity("span2") + }; + + var sampler = new MockSampler(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[] + { + CreateTestActivity("span1") + }; + + var sampler = new MockSampler(new Dictionary + { + [activities[0].SpanId.ToString()] = 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); + } + } +} \ No newline at end of file From 2b13937a55cfa4dbab2913d8d136e78103b31834 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:53:05 -0700 Subject: [PATCH 2/7] Consolidated mock sampler. --- .../SamplingLogProcessorTests.cs | 71 +++++------------ .../TestActivityHelper.cs | 45 +++++++++++ .../TestSamplerHelper.cs | 79 +++++++++++++++++++ 3 files changed, 145 insertions(+), 50 deletions(-) create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestActivityHelper.cs create mode 100644 sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs index defad12205..ba63d56108 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs @@ -12,51 +12,7 @@ namespace LaunchDarkly.Observability.Test [TestFixture] public class SamplingLogProcessorTests { - #region Helper Classes - /// - /// Mock sampler for testing SamplingLogProcessor - /// - private class MockLogSampler : IExportSampler - { - private readonly bool _shouldSample; - private readonly Dictionary _attributesToAdd; - private readonly bool _enabled; - - public MockLogSampler(bool shouldSample, Dictionary attributesToAdd = null, - bool enabled = true) - { - _shouldSample = shouldSample; - _attributesToAdd = attributesToAdd ?? new Dictionary(); - _enabled = enabled; - } - - public SamplingResult SampleLog(LogRecord record) - { - return new SamplingResult - { - Sample = _shouldSample, - Attributes = _attributesToAdd - }; - } - - public SamplingResult SampleSpan(Activity span) - { - return new SamplingResult { Sample = true }; - } - - public bool IsSamplingEnabled() - { - return _enabled; - } - - public void SetConfig(SamplingConfig config) - { - // No-op for tests - } - } - - #endregion #region Tests @@ -64,7 +20,7 @@ public void SetConfig(SamplingConfig config) [Test] public void OnEnd_WhenSamplerReturnsFalse_ShouldNotModifyAttributes() { - var sampler = new MockLogSampler(shouldSample: false); + var sampler = TestSamplerHelper.CreateMockSampler(shouldSampleLogs: false); var processor = new SamplingLogProcessor(sampler); var logRecord = LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message"); var originalAttributes = logRecord.Attributes?.ToList(); @@ -86,7 +42,10 @@ public void OnEnd_WhenSamplerReturnsFalse_ShouldNotModifyAttributes() [Test] public void OnEnd_WhenSamplerReturnsTrue_WithNoAttributes_ShouldNotModifyRecord() { - var sampler = new MockLogSampler(shouldSample: true, attributesToAdd: new Dictionary()); + 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(); @@ -113,7 +72,10 @@ public void OnEnd_WhenSamplerAddsAttributes_ShouldMergeWithExistingAttributes() ["sampling.ratio"] = 0.5, ["sampler.type"] = "custom" }; - var sampler = new MockLogSampler(shouldSample: true, attributesToAdd: samplingAttributes); + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: samplingAttributes + ); var processor = new SamplingLogProcessor(sampler); var existingAttributes = new Dictionary @@ -167,7 +129,10 @@ public void OnEnd_WhenLogHasNoExistingAttributes_ShouldAddSamplingAttributes() ["sampling.ratio"] = 1.0, ["sampler.enabled"] = true }; - var sampler = new MockLogSampler(shouldSample: true, attributesToAdd: samplingAttributes); + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: samplingAttributes + ); var processor = new SamplingLogProcessor(sampler); var logRecord = LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message without attributes"); @@ -201,7 +166,10 @@ public void OnEnd_WhenSamplingRatioAttributeIsAdded_ShouldBeIncludedInLogAttribu { ["sampling.ratio"] = 0.25 }; - var sampler = new MockLogSampler(shouldSample: true, attributesToAdd: samplingAttributes); + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: samplingAttributes + ); var processor = new SamplingLogProcessor(sampler); var logRecord = LogRecordHelper.CreateTestLogRecord(LogLevel.Information, "Test message for sampling ratio"); @@ -221,7 +189,10 @@ public void OnEnd_WhenSamplingRatioAttributeIsAdded_ShouldBeIncludedInLogAttribu [Test] public void OnEnd_WhenSamplerHasEmptyAttributes_ShouldNotAddAttributes() { - var sampler = new MockLogSampler(shouldSample: true, attributesToAdd: new Dictionary()); + var sampler = TestSamplerHelper.CreateMockSampler( + shouldSampleLogs: true, + attributesToAdd: new Dictionary() + ); var processor = new SamplingLogProcessor(sampler); var existingAttributes = new Dictionary 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 0000000000..6abebcee7d --- /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; + } + } +} \ No newline at end of file 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 0000000000..63340d5114 --- /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 _spanSampleResults; + private readonly bool _shouldSampleLogs; + private readonly Dictionary _attributesToAdd; + private readonly bool _enabled; + + 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; + } + } + } +} \ No newline at end of file From c63315bed27c463a829794bb740fa3e316cae0c2 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 15:58:17 -0700 Subject: [PATCH 3/7] De-duplicate activity creation. --- .../SampleSpansTests.cs | 147 +++++------------ .../SamplingTraceExporterTests.cs | 154 ++++++------------ 2 files changed, 88 insertions(+), 213 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs index 64da1a95a8..e060f6ae50 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs @@ -11,104 +11,27 @@ namespace LaunchDarkly.Observability.Test [TestFixture] public class SampleSpansTests { - #region Helper Classes and Methods - - /// - /// Mock implementation of IExportSampler for testing - /// - private class MockSampler : IExportSampler - { - private readonly Dictionary _mockResults; - private readonly bool _enabled; - - public MockSampler(Dictionary mockResults, bool enabled = true) - { - _mockResults = mockResults; - _enabled = enabled; - } - - public void SetConfig(SamplingConfig config) - { - // Not needed for tests - } - - public SamplingResult SampleSpan(Activity span) - { - var spanId = span.SpanId.ToString(); - var shouldSample = _mockResults.GetValueOrDefault(spanId, true); - - return new SamplingResult - { - Sample = shouldSample, - Attributes = shouldSample - ? new Dictionary { ["samplingRatio"] = 2 } - : new Dictionary() - }; - } - - public SamplingResult SampleLog(LogRecord record) - { - return new SamplingResult { Sample = true }; - } - - public bool IsSamplingEnabled() - { - return _enabled; - } - } - - /// - /// Helper method to create a mock Activity - /// - private static Activity CreateMockActivity(string name, string parentSpanId = null) - { - var activity = new Activity(name); - activity.Start(); - activity.DisplayName = name; - - // Set a custom span ID for predictable testing - 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; - } - - #endregion - [Test] public void SampleActivities_ShouldRemoveSpansThatAreNotSampled() { var activities = new List { - CreateMockActivity("span-1"), // Root span - sampled - CreateMockActivity("span-2") // Root span - not sampled + 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 = new MockSampler(new Dictionary - { - [span1.SpanId.ToString()] = true, - [span2.SpanId.ToString()] = false - }); + var samplerWithRealIds = TestSamplerHelper.CreateMockSampler( + spanSampleResults: new Dictionary + { + [span1.SpanId.ToString()] = true, + [span2.SpanId.ToString()] = false + }, + attributesToAdd: new Dictionary { ["samplingRatio"] = 2 } + ); // Act var sampledActivities = SampleSpans.SampleActivities(activities, samplerWithRealIds); @@ -124,10 +47,11 @@ public void SampleActivities_ShouldRemoveSpansThatAreNotSampled() public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled() { // Arrange - Create span hierarchy with parent -> child -> grandchild - var parentActivity = CreateMockActivity("parent"); - var childActivity = CreateMockActivity("child", parentActivity.SpanId.ToString()); - var grandchildActivity = CreateMockActivity("grandchild", childActivity.SpanId.ToString()); - var rootActivity = CreateMockActivity("root"); + 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 { @@ -137,13 +61,15 @@ public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled() rootActivity }; - var mockSampler = new MockSampler(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 - }); + var mockSampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: 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); @@ -157,15 +83,15 @@ public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled() public void SampleActivities_ShouldNotApplySamplingWhenSamplingIsDisabled() { // Arrange - var mockSampler = new MockSampler( - new Dictionary(), // Empty results + var mockSampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: new Dictionary(), // Empty results enabled: false // Sampling disabled ); var activities = new List { - CreateMockActivity("span-1"), - CreateMockActivity("span-2") + TestActivityHelper.CreateTestActivity("span-1"), + TestActivityHelper.CreateTestActivity("span-2") }; // Act @@ -182,15 +108,18 @@ public void SampleActivities_ShouldApplySamplingAttributesToSampledSpans() // Arrange var activities = new List { - CreateMockActivity("span-1"), - CreateMockActivity("span-2") + TestActivityHelper.CreateTestActivity("span-1"), + TestActivityHelper.CreateTestActivity("span-2") }; - var mockSampler = new MockSampler(new Dictionary - { - [activities[0].SpanId.ToString()] = true, - [activities[1].SpanId.ToString()] = true - }); + var mockSampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: new Dictionary + { + [activities[0].SpanId.ToString()] = true, + [activities[1].SpanId.ToString()] = true + }, + attributesToAdd: new Dictionary { ["samplingRatio"] = 2 } + ); // Act var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler); diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs index cddf80115f..62f7f19523 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs @@ -11,85 +11,19 @@ namespace LaunchDarkly.Observability.Test [TestFixture] public class SamplingTraceExporterTests { - #region Helper Classes - - /// - /// Mock sampler for testing - /// - private class MockSampler : IExportSampler - { - private readonly Dictionary _sampleResults; - private readonly bool _enabled; - - public MockSampler(Dictionary sampleResults, bool enabled = true) - { - _sampleResults = sampleResults; - _enabled = enabled; - } - - public void SetConfig(SamplingConfig config) { } - - public SamplingResult SampleSpan(Activity span) - { - var spanId = span.SpanId.ToString(); - var shouldSample = _sampleResults.ContainsKey(spanId) ? _sampleResults[spanId] : true; - - return new SamplingResult - { - Sample = shouldSample, - Attributes = shouldSample ? new Dictionary { ["test.sampled"] = true } : new Dictionary() - }; - } - - public SamplingResult SampleLog(LogRecord record) - { - return new SamplingResult { Sample = true }; - } - - public bool IsSamplingEnabled() - { - return _enabled; - } - } - - /// - /// Helper to create test activities - /// - private static Activity CreateTestActivity(string name, string parentSpanId = null) - { - var activity = new Activity(name); - activity.Start(); - activity.DisplayName = name; - - if (!string.IsNullOrEmpty(parentSpanId)) - { - var traceId = ActivityTraceId.CreateRandom(); - var parentSpan = ActivitySpanId.CreateFromString(parentSpanId.PadRight(16, '0')); - var parentContext = new ActivityContext(traceId, parentSpan, ActivityTraceFlags.Recorded); - - activity.Stop(); - activity = new Activity(name); - activity.SetParentId(parentContext.TraceId, parentContext.SpanId, parentContext.TraceFlags); - activity.Start(); - activity.DisplayName = name; - } - - activity.Stop(); - return activity; - } - - #endregion - [Test] public void SampleActivities_WhenSamplingDisabled_ShouldReturnAllActivities() { // Arrange - var sampler = new MockSampler(new Dictionary(), enabled: false); + var sampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: new Dictionary(), + enabled: false + ); var activities = new[] { - CreateTestActivity("span1"), - CreateTestActivity("span2") + TestActivityHelper.CreateTestActivity("span1"), + TestActivityHelper.CreateTestActivity("span2") }; // Act @@ -106,17 +40,20 @@ public void SampleActivities_WhenSpansAreFilteredOut_ShouldReturnOnlySelectedSpa // Arrange var activities = new[] { - CreateTestActivity("span1"), - CreateTestActivity("span2"), - CreateTestActivity("span3") + TestActivityHelper.CreateTestActivity("span1"), + TestActivityHelper.CreateTestActivity("span2"), + TestActivityHelper.CreateTestActivity("span3") }; - var sampler = new MockSampler(new Dictionary - { - [activities[0].SpanId.ToString()] = true, // Include - [activities[1].SpanId.ToString()] = false, // Exclude - [activities[2].SpanId.ToString()] = true // Include - }); + var sampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: 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); @@ -132,18 +69,21 @@ public void SampleActivities_WhenSpansAreFilteredOut_ShouldReturnOnlySelectedSpa public void SampleActivities_WhenParentSpanIsFilteredOut_ShouldAlsoFilterOutChildren() { // Arrange - var parentActivity = CreateTestActivity("parent"); - var childActivity = CreateTestActivity("child", parentActivity.SpanId.ToString()); - var independentActivity = CreateTestActivity("independent"); + 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 = new MockSampler(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 - }); + var sampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: 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); @@ -159,15 +99,17 @@ public void SampleActivities_WhenNoActivitiesPassSampling_ShouldReturnEmptyList( // Arrange var activities = new[] { - CreateTestActivity("span1"), - CreateTestActivity("span2") + TestActivityHelper.CreateTestActivity("span1"), + TestActivityHelper.CreateTestActivity("span2") }; - var sampler = new MockSampler(new Dictionary - { - [activities[0].SpanId.ToString()] = false, - [activities[1].SpanId.ToString()] = false - }); + var sampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: new Dictionary + { + [activities[0].SpanId.ToString()] = false, + [activities[1].SpanId.ToString()] = false + } + ); // Act var sampledActivities = SampleSpans.SampleActivities(activities, sampler); @@ -182,20 +124,24 @@ public void SampleActivities_WhenSamplingAttributesAreAdded_ShouldIncludeThemInA // Arrange var activities = new[] { - CreateTestActivity("span1") + TestActivityHelper.CreateTestActivity("span1") }; - var sampler = new MockSampler(new Dictionary - { - [activities[0].SpanId.ToString()] = true - }); + var sampler = TestSamplerHelper.CreateMockSampler( + spanSampleResults: 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); + Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "test.sampled" && t.Value.Equals(true)), + Is.True); } } -} \ No newline at end of file +} From 389aa3c75ff01180679cc349803ef51da305393d Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:03:09 -0700 Subject: [PATCH 4/7] Formatting. --- .../Otel/SamplingLogProcessor.cs | 21 ++- .../Otel/SamplingTraceExporter.cs | 14 +- .../Sampling/CustomSampler.cs | 147 ++++++++---------- .../Sampling/IExportSampler.cs | 2 +- .../Sampling/SampleSpans.cs | 15 +- .../Sampling/SamplingConfig.cs | 10 +- .../Sampling/ThreadSafeSampler.cs | 4 +- .../LaunchDarkly.Observability.Tests.csproj | 4 +- .../ObservabilityPluginBuilderTests.cs | 6 +- .../SamplingLogProcessorTests.cs | 3 - 10 files changed, 94 insertions(+), 132 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs index 26c75a5156..a65a996f1a 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs @@ -6,19 +6,20 @@ 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. + /// 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 + internal class SamplingLogProcessor : BaseProcessor { - private IExportSampler _sampler; + private readonly IExportSampler _sampler; public SamplingLogProcessor(IExportSampler sampler) { _sampler = sampler; } + public override void OnEnd(LogRecord data) { var res = _sampler.SampleLog(data); @@ -26,13 +27,11 @@ public override void OnEnd(LogRecord data) if (res.Attributes != null && res.Attributes.Count > 0) { var combinedAttributes = new List>(res.Attributes); - if (data.Attributes != null) - { - combinedAttributes.AddRange(data.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 index d11ed8d9e7..593a948ed4 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs @@ -7,30 +7,24 @@ namespace LaunchDarkly.Observability.Otel { /// - /// Custom trace exporter that applies sampling before exporting + /// Custom trace exporter that applies sampling before exporting /// internal class SamplingTraceExporter : OtlpTraceExporter { private readonly IExportSampler _sampler; - public SamplingTraceExporter(IExportSampler sampler, OtlpExporterOptions options): base(options) + public SamplingTraceExporter(IExportSampler sampler, OtlpExporterOptions options) : base(options) { _sampler = sampler; } public override ExportResult Export(in Batch batch) { - if (!_sampler.IsSamplingEnabled()) - { - return base.Export(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); - } + foreach (var activity in batch) activities.Add(activity); var sampledActivities = SampleSpans.SampleActivities(activities, _sampler); if (sampledActivities.Count == 0) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs index baec84acbe..192d131ccf 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs @@ -11,8 +11,8 @@ namespace LaunchDarkly.Observability.Sampling { /// - /// Volatile wrapper for the sampling config. - /// This is a lock-free method to provide visibility of the config between threads. + /// Volatile wrapper for the sampling config. + /// This is a lock-free method to provide visibility of the config between threads. /// internal class ThreadSafeConfig { @@ -30,12 +30,12 @@ public SamplingConfig GetSamplingConfig() } /// - /// Function type for sampling decisions + /// Function type for sampling decisions /// internal delegate bool SamplerFunc(int ratio); /// - /// Default sampler implementation. + /// Default sampler implementation. /// internal static class DefaultSampler { @@ -47,19 +47,19 @@ public static bool Sample(int ratio) internal class CustomSampler : IExportSampler { - private readonly SamplerFunc _sampler; - private readonly ThreadSafeConfig _config = new ThreadSafeConfig(); - private readonly ConcurrentDictionary _regexCache = new ConcurrentDictionary(); - private const string SamplingRatioAttribute = "launchdarkly.sampling.ratio"; /// - /// Delta between two numbers which will be considered equal. + /// Delta between two numbers which will be considered equal. /// private const double Epsilon = 0.0000000000000001; + private readonly ThreadSafeConfig _config = new ThreadSafeConfig(); + private readonly ConcurrentDictionary _regexCache = new ConcurrentDictionary(); + private readonly SamplerFunc _sampler; + /// - /// Represents the result of sampling a span or log + /// Represents the result of sampling a span or log /// public CustomSampler(SamplerFunc sampler = null) { @@ -67,7 +67,7 @@ public CustomSampler(SamplerFunc sampler = null) } /// - /// Set the sampling configuration. + /// Set the sampling configuration. /// /// the new configuration public void SetConfig(SamplingConfig config) @@ -76,10 +76,10 @@ public void SetConfig(SamplingConfig config) } /// - /// Check if sampling is enabled. - /// - /// Sampling is enabled if there is at least one configuration in either the log or span sampling. - /// + /// Check if sampling is enabled. + /// + /// Sampling is enabled if there is at least one configuration in either the log or span sampling. + /// /// /// true if sampling is enabled public bool IsSamplingEnabled() @@ -92,6 +92,54 @@ public bool IsSamplingEnabled() return hasLogConfig || hasSpanConfig; } + /// + /// Sample a span. + /// + /// the span to sample + /// the sampling result + public SamplingResult SampleSpan(Activity span) + { + var config = _config.GetSamplingConfig(); + if (!(config?.Spans.Count > 0)) return new SamplingResult { Sample = true }; + foreach (var spanConfig in config.Spans) + if (MatchesSpanConfig(spanConfig, span)) + return new SamplingResult + { + Sample = _sampler(spanConfig.SamplingRatio), + Attributes = new Dictionary + { + [SamplingRatioAttribute] = spanConfig.SamplingRatio + } + }; + + // Default to sampling if no config matches + return new SamplingResult { Sample = true }; + } + + /// + /// Sample a log record. + /// + /// the log record to sample + /// the sampling result + public SamplingResult SampleLog(LogRecord record) + { + var config = _config.GetSamplingConfig(); + if (!(config?.Logs.Count > 0)) return new SamplingResult { Sample = true }; + foreach (var logConfig in config.Logs) + if (MatchesLogConfig(logConfig, record)) + return new SamplingResult + { + Sample = _sampler(logConfig.SamplingRatio), + Attributes = new Dictionary + { + [SamplingRatioAttribute] = logConfig.SamplingRatio + } + }; + + // Default to sampling if no config matches + return new SamplingResult { Sample = true }; + } + private Regex GetCachedRegex(string pattern) { return _regexCache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.Compiled)); @@ -130,7 +178,6 @@ private bool MatchesValue(SamplingConfig.MatchConfig matchConfig, object value) { // Handle JsonElement from JSON deserialization if (matchConfig.MatchValue is JsonElement jsonElement) - { switch (jsonElement.ValueKind) { case JsonValueKind.String: @@ -153,11 +200,8 @@ private bool MatchesValue(SamplingConfig.MatchConfig matchConfig, object value) default: break; } - } else - { return matchConfig.MatchValue.Equals(value); - } } // Check regex match @@ -215,10 +259,8 @@ private bool MatchEvent(SamplingConfig.EventMatchConfig eventConfig, ActivityEve { // Match by event name if specified if (!IsMatchConfigEmpty(eventConfig.Name)) - { if (!MatchesValue(eventConfig.Name, activityEvent.Name)) return false; - } // Match by event attributes if specified return eventConfig.Attributes.Count <= 0 || @@ -228,52 +270,19 @@ private bool MatchEvent(SamplingConfig.EventMatchConfig eventConfig, ActivityEve private bool MatchesSpanConfig(SamplingConfig.SpanSamplingConfig config, Activity span) { // Check span name if defined - if (!IsMatchConfigEmpty(config.Name) && !MatchesValue(config.Name, span.DisplayName)) - { - return false; - } + if (!IsMatchConfigEmpty(config.Name) && !MatchesValue(config.Name, span.DisplayName)) return false; return MatchesAttributes(config.Attributes, span.TagObjects.ToList()) && // Check events MatchesEvents(config.Events, span.Events.ToList()); } - /// - /// Sample a span. - /// - /// the span to sample - /// the sampling result - public SamplingResult SampleSpan(Activity span) - { - var config = _config.GetSamplingConfig(); - if (!(config?.Spans.Count > 0)) return new SamplingResult { Sample = true }; - foreach (var spanConfig in config.Spans) - { - if (MatchesSpanConfig(spanConfig, span)) - { - return new SamplingResult - { - Sample = _sampler(spanConfig.SamplingRatio), - Attributes = new Dictionary - { - [SamplingRatioAttribute] = spanConfig.SamplingRatio - } - }; - } - } - - // Default to sampling if no config matches - return new SamplingResult { Sample = true }; - } - private bool MatchesLogConfig(SamplingConfig.LogSamplingConfig config, LogRecord record) { // Check severity text if defined if (!IsMatchConfigEmpty(config.SeverityText)) - { if (!MatchesValue(config.SeverityText, record.LogLevel.ToString())) return false; - } // Check message if defined if (!IsMatchConfigEmpty(config.Message)) @@ -288,33 +297,5 @@ private bool MatchesLogConfig(SamplingConfig.LogSamplingConfig config, LogRecord return record.Attributes != null && MatchesAttributes(config.Attributes, record.Attributes.ToList()); } - - /// - /// Sample a log record. - /// - /// the log record to sample - /// the sampling result - public SamplingResult SampleLog(LogRecord record) - { - var config = _config.GetSamplingConfig(); - if (!(config?.Logs.Count > 0)) return new SamplingResult { Sample = true }; - foreach (var logConfig in config.Logs) - { - if (MatchesLogConfig(logConfig, record)) - { - return new SamplingResult - { - Sample = _sampler(logConfig.SamplingRatio), - Attributes = new Dictionary - { - [SamplingRatioAttribute] = logConfig.SamplingRatio - } - }; - } - } - - // Default to sampling if no config matches - return new SamplingResult { Sample = true }; - } } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs index 471a52489d..f104de668f 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs @@ -9,7 +9,7 @@ internal class SamplingResult public bool Sample { get; set; } public Dictionary Attributes { get; set; } = new Dictionary(); } - + internal interface IExportSampler { SamplingResult SampleSpan(Activity span); diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs index c83555f1f3..768a5ce766 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs @@ -5,22 +5,19 @@ namespace LaunchDarkly.Observability.Sampling { /// - /// Utilities for sampling spans including hierarchical span 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 + /// 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(); - } + if (!sampler.IsSamplingEnabled()) return activities.ToList(); var omittedSpanIds = new List(); var activityById = new Dictionary(); @@ -37,9 +34,7 @@ public static List SampleActivities(IEnumerable activities, { var parentSpanId = activity.ParentSpanId.ToString(); if (!childrenByParentId.ContainsKey(parentSpanId)) - { childrenByParentId[parentSpanId] = new List(); - } childrenByParentId[parentSpanId].Add(spanId); } @@ -49,12 +44,8 @@ public static List SampleActivities(IEnumerable activities, 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; } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs index 34fee3f588..5eb98fffec 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs @@ -5,6 +5,11 @@ namespace LaunchDarkly.Observability.Sampling { internal class SamplingConfig { + [JsonPropertyName("spans")] + public List Spans { get; set; } = new List(); + + [JsonPropertyName("logs")] public List Logs { get; set; } = new List(); + internal class MatchConfig { [JsonPropertyName("matchValue")] public object MatchValue { get; set; } @@ -51,10 +56,5 @@ internal class LogSamplingConfig [JsonPropertyName("samplingRatio")] public int SamplingRatio { get; set; } = 1; } - - [JsonPropertyName("spans")] - public List Spans { get; set; } = new List(); - - [JsonPropertyName("logs")] public List Logs { get; set; } = new List(); } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs index 8036e54ad4..a64ef9b7bb 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs @@ -3,7 +3,7 @@ namespace LaunchDarkly.Observability.Sampling { /// - /// Class used for event sampling. + /// Class used for event sampling. /// public static class ThreadSafeSampler { @@ -11,7 +11,7 @@ public static class ThreadSafeSampler private static readonly object RandLock = new object(); /// - /// Given a ratio determine if an event should be sampled. + /// Given a ratio determine if an event should be sampled. /// /// This function is thread-safe. /// 0 means never sample and 1 means always sample diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj index 5b3344f8c2..bf1eb0ab3e 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj @@ -18,11 +18,11 @@ - + - + diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs index 3623679ea5..232d9b1c6f 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs @@ -1,20 +1,20 @@ -using NUnit.Framework; using System; using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; namespace LaunchDarkly.Observability.Test { [TestFixture] public class ObservabilityPluginBuilderTests { - private IServiceCollection _services; - [SetUp] public void SetUp() { _services = new ServiceCollection(); } + private IServiceCollection _services; + [Test] public void CreateBuilder_WithValidParameters_CreatesBuilder() { diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs index ba63d56108..d2274281af 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs @@ -12,9 +12,6 @@ namespace LaunchDarkly.Observability.Test [TestFixture] public class SamplingLogProcessorTests { - - - #region Tests [Test] From 51fb61986dbd75a32bb0b00edb358cd754752fef Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:06:23 -0700 Subject: [PATCH 5/7] Revert format only changes. --- .../Sampling/CustomSampler.cs | 147 ++++++++++-------- .../Sampling/IExportSampler.cs | 2 +- .../Sampling/SamplingConfig.cs | 10 +- .../Sampling/ThreadSafeSampler.cs | 4 +- .../CustomSamplerTests.cs | 25 ++- .../LaunchDarkly.Observability.Tests.csproj | 4 +- .../ObservabilityPluginBuilderTests.cs | 6 +- 7 files changed, 117 insertions(+), 81 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs index 192d131ccf..baec84acbe 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/CustomSampler.cs @@ -11,8 +11,8 @@ namespace LaunchDarkly.Observability.Sampling { /// - /// Volatile wrapper for the sampling config. - /// This is a lock-free method to provide visibility of the config between threads. + /// Volatile wrapper for the sampling config. + /// This is a lock-free method to provide visibility of the config between threads. /// internal class ThreadSafeConfig { @@ -30,12 +30,12 @@ public SamplingConfig GetSamplingConfig() } /// - /// Function type for sampling decisions + /// Function type for sampling decisions /// internal delegate bool SamplerFunc(int ratio); /// - /// Default sampler implementation. + /// Default sampler implementation. /// internal static class DefaultSampler { @@ -47,19 +47,19 @@ public static bool Sample(int ratio) internal class CustomSampler : IExportSampler { + private readonly SamplerFunc _sampler; + private readonly ThreadSafeConfig _config = new ThreadSafeConfig(); + private readonly ConcurrentDictionary _regexCache = new ConcurrentDictionary(); + private const string SamplingRatioAttribute = "launchdarkly.sampling.ratio"; /// - /// Delta between two numbers which will be considered equal. + /// Delta between two numbers which will be considered equal. /// private const double Epsilon = 0.0000000000000001; - private readonly ThreadSafeConfig _config = new ThreadSafeConfig(); - private readonly ConcurrentDictionary _regexCache = new ConcurrentDictionary(); - private readonly SamplerFunc _sampler; - /// - /// Represents the result of sampling a span or log + /// Represents the result of sampling a span or log /// public CustomSampler(SamplerFunc sampler = null) { @@ -67,7 +67,7 @@ public CustomSampler(SamplerFunc sampler = null) } /// - /// Set the sampling configuration. + /// Set the sampling configuration. /// /// the new configuration public void SetConfig(SamplingConfig config) @@ -76,10 +76,10 @@ public void SetConfig(SamplingConfig config) } /// - /// Check if sampling is enabled. - /// - /// Sampling is enabled if there is at least one configuration in either the log or span sampling. - /// + /// Check if sampling is enabled. + /// + /// Sampling is enabled if there is at least one configuration in either the log or span sampling. + /// /// /// true if sampling is enabled public bool IsSamplingEnabled() @@ -92,54 +92,6 @@ public bool IsSamplingEnabled() return hasLogConfig || hasSpanConfig; } - /// - /// Sample a span. - /// - /// the span to sample - /// the sampling result - public SamplingResult SampleSpan(Activity span) - { - var config = _config.GetSamplingConfig(); - if (!(config?.Spans.Count > 0)) return new SamplingResult { Sample = true }; - foreach (var spanConfig in config.Spans) - if (MatchesSpanConfig(spanConfig, span)) - return new SamplingResult - { - Sample = _sampler(spanConfig.SamplingRatio), - Attributes = new Dictionary - { - [SamplingRatioAttribute] = spanConfig.SamplingRatio - } - }; - - // Default to sampling if no config matches - return new SamplingResult { Sample = true }; - } - - /// - /// Sample a log record. - /// - /// the log record to sample - /// the sampling result - public SamplingResult SampleLog(LogRecord record) - { - var config = _config.GetSamplingConfig(); - if (!(config?.Logs.Count > 0)) return new SamplingResult { Sample = true }; - foreach (var logConfig in config.Logs) - if (MatchesLogConfig(logConfig, record)) - return new SamplingResult - { - Sample = _sampler(logConfig.SamplingRatio), - Attributes = new Dictionary - { - [SamplingRatioAttribute] = logConfig.SamplingRatio - } - }; - - // Default to sampling if no config matches - return new SamplingResult { Sample = true }; - } - private Regex GetCachedRegex(string pattern) { return _regexCache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.Compiled)); @@ -178,6 +130,7 @@ private bool MatchesValue(SamplingConfig.MatchConfig matchConfig, object value) { // Handle JsonElement from JSON deserialization if (matchConfig.MatchValue is JsonElement jsonElement) + { switch (jsonElement.ValueKind) { case JsonValueKind.String: @@ -200,8 +153,11 @@ private bool MatchesValue(SamplingConfig.MatchConfig matchConfig, object value) default: break; } + } else + { return matchConfig.MatchValue.Equals(value); + } } // Check regex match @@ -259,8 +215,10 @@ private bool MatchEvent(SamplingConfig.EventMatchConfig eventConfig, ActivityEve { // Match by event name if specified if (!IsMatchConfigEmpty(eventConfig.Name)) + { if (!MatchesValue(eventConfig.Name, activityEvent.Name)) return false; + } // Match by event attributes if specified return eventConfig.Attributes.Count <= 0 || @@ -270,19 +228,52 @@ private bool MatchEvent(SamplingConfig.EventMatchConfig eventConfig, ActivityEve private bool MatchesSpanConfig(SamplingConfig.SpanSamplingConfig config, Activity span) { // Check span name if defined - if (!IsMatchConfigEmpty(config.Name) && !MatchesValue(config.Name, span.DisplayName)) return false; + if (!IsMatchConfigEmpty(config.Name) && !MatchesValue(config.Name, span.DisplayName)) + { + return false; + } return MatchesAttributes(config.Attributes, span.TagObjects.ToList()) && // Check events MatchesEvents(config.Events, span.Events.ToList()); } + /// + /// Sample a span. + /// + /// the span to sample + /// the sampling result + public SamplingResult SampleSpan(Activity span) + { + var config = _config.GetSamplingConfig(); + if (!(config?.Spans.Count > 0)) return new SamplingResult { Sample = true }; + foreach (var spanConfig in config.Spans) + { + if (MatchesSpanConfig(spanConfig, span)) + { + return new SamplingResult + { + Sample = _sampler(spanConfig.SamplingRatio), + Attributes = new Dictionary + { + [SamplingRatioAttribute] = spanConfig.SamplingRatio + } + }; + } + } + + // Default to sampling if no config matches + return new SamplingResult { Sample = true }; + } + private bool MatchesLogConfig(SamplingConfig.LogSamplingConfig config, LogRecord record) { // Check severity text if defined if (!IsMatchConfigEmpty(config.SeverityText)) + { if (!MatchesValue(config.SeverityText, record.LogLevel.ToString())) return false; + } // Check message if defined if (!IsMatchConfigEmpty(config.Message)) @@ -297,5 +288,33 @@ private bool MatchesLogConfig(SamplingConfig.LogSamplingConfig config, LogRecord return record.Attributes != null && MatchesAttributes(config.Attributes, record.Attributes.ToList()); } + + /// + /// Sample a log record. + /// + /// the log record to sample + /// the sampling result + public SamplingResult SampleLog(LogRecord record) + { + var config = _config.GetSamplingConfig(); + if (!(config?.Logs.Count > 0)) return new SamplingResult { Sample = true }; + foreach (var logConfig in config.Logs) + { + if (MatchesLogConfig(logConfig, record)) + { + return new SamplingResult + { + Sample = _sampler(logConfig.SamplingRatio), + Attributes = new Dictionary + { + [SamplingRatioAttribute] = logConfig.SamplingRatio + } + }; + } + } + + // Default to sampling if no config matches + return new SamplingResult { Sample = true }; + } } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs index f104de668f..471a52489d 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/IExportSampler.cs @@ -9,7 +9,7 @@ internal class SamplingResult public bool Sample { get; set; } public Dictionary Attributes { get; set; } = new Dictionary(); } - + internal interface IExportSampler { SamplingResult SampleSpan(Activity span); diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs index 5eb98fffec..34fee3f588 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SamplingConfig.cs @@ -5,11 +5,6 @@ namespace LaunchDarkly.Observability.Sampling { internal class SamplingConfig { - [JsonPropertyName("spans")] - public List Spans { get; set; } = new List(); - - [JsonPropertyName("logs")] public List Logs { get; set; } = new List(); - internal class MatchConfig { [JsonPropertyName("matchValue")] public object MatchValue { get; set; } @@ -56,5 +51,10 @@ internal class LogSamplingConfig [JsonPropertyName("samplingRatio")] public int SamplingRatio { get; set; } = 1; } + + [JsonPropertyName("spans")] + public List Spans { get; set; } = new List(); + + [JsonPropertyName("logs")] public List Logs { get; set; } = new List(); } } diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs index a64ef9b7bb..8036e54ad4 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/ThreadSafeSampler.cs @@ -3,7 +3,7 @@ namespace LaunchDarkly.Observability.Sampling { /// - /// Class used for event sampling. + /// Class used for event sampling. /// public static class ThreadSafeSampler { @@ -11,7 +11,7 @@ public static class ThreadSafeSampler private static readonly object RandLock = new object(); /// - /// Given a ratio determine if an event should be sampled. + /// Given a ratio determine if an event should be sampled. /// /// This function is thread-safe. /// 0 means never sample and 1 means always sample 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 4559e6f18e..6aa11999df 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs @@ -302,6 +302,17 @@ 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) @@ -309,15 +320,21 @@ 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); + var record = records.First(); 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 ?? 0)); + Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count)); }); var res = sampler.SampleLog(record); diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj index bf1eb0ab3e..5b3344f8c2 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LaunchDarkly.Observability.Tests.csproj @@ -18,11 +18,11 @@ - + - + diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs index 232d9b1c6f..3623679ea5 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/ObservabilityPluginBuilderTests.cs @@ -1,20 +1,20 @@ +using NUnit.Framework; using System; using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; namespace LaunchDarkly.Observability.Test { [TestFixture] public class ObservabilityPluginBuilderTests { + private IServiceCollection _services; + [SetUp] public void SetUp() { _services = new ServiceCollection(); } - private IServiceCollection _services; - [Test] public void CreateBuilder_WithValidParameters_CreatesBuilder() { From f957d60803bab919c98b65b9f72cddee4661a6b1 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:08:32 -0700 Subject: [PATCH 6/7] More formatting. --- .../Otel/SamplingLogProcessor.cs | 8 ++++---- .../Otel/SamplingTraceExporter.cs | 2 +- .../Sampling/SampleSpans.cs | 4 ++-- .../LogRecordHelper.cs | 4 ---- .../SampleSpansTests.cs | 10 ++++------ .../SamplingLogProcessorTests.cs | 15 --------------- .../SamplingTraceExporterTests.cs | 13 +++++-------- .../TestActivityHelper.cs | 2 +- .../TestSamplerHelper.cs | 6 +++--- 9 files changed, 20 insertions(+), 44 deletions(-) diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs index a65a996f1a..f28d64c661 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs @@ -6,10 +6,10 @@ 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. + /// 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 { diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs index 593a948ed4..2abffea0ed 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs @@ -7,7 +7,7 @@ namespace LaunchDarkly.Observability.Otel { /// - /// Custom trace exporter that applies sampling before exporting + /// Custom trace exporter that applies sampling before exporting /// internal class SamplingTraceExporter : OtlpTraceExporter { diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs index 768a5ce766..842faaa0e3 100644 --- a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs +++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs @@ -5,12 +5,12 @@ namespace LaunchDarkly.Observability.Sampling { /// - /// Utilities for sampling spans including hierarchical span 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 + /// Sample spans with hierarchical logic that removes children of sampled-out spans /// /// Collection of activities to sample /// The sampler to use for sampling decisions diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs index 697795abfd..254374856c 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs @@ -29,15 +29,11 @@ public static LogRecord CreateTestLogRecord(LogLevel level, string message, // 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 index e060f6ae50..7d8154eea4 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SampleSpansTests.cs @@ -1,10 +1,8 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using LaunchDarkly.Observability.Sampling; using NUnit.Framework; -using OpenTelemetry.Logs; namespace LaunchDarkly.Observability.Test { @@ -25,7 +23,7 @@ public void SampleActivities_ShouldRemoveSpansThatAreNotSampled() // We need to mock the span IDs in our sampler var samplerWithRealIds = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [span1.SpanId.ToString()] = true, [span2.SpanId.ToString()] = false @@ -62,7 +60,7 @@ public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled() }; var mockSampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [parentActivity.SpanId.ToString()] = false, // Parent not sampled [childActivity.SpanId.ToString()] = true, // Child would be sampled but parent isn't @@ -84,7 +82,7 @@ public void SampleActivities_ShouldNotApplySamplingWhenSamplingIsDisabled() { // Arrange var mockSampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary(), // Empty results + new Dictionary(), // Empty results enabled: false // Sampling disabled ); @@ -113,7 +111,7 @@ public void SampleActivities_ShouldApplySamplingAttributesToSampledSpans() }; var mockSampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [activities[0].SpanId.ToString()] = true, [activities[1].SpanId.ToString()] = 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 index d2274281af..bec1620ee6 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingLogProcessorTests.cs @@ -1,19 +1,14 @@ using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using LaunchDarkly.Observability.Otel; -using LaunchDarkly.Observability.Sampling; using Microsoft.Extensions.Logging; using NUnit.Framework; -using OpenTelemetry.Logs; namespace LaunchDarkly.Observability.Test { [TestFixture] public class SamplingLogProcessorTests { - #region Tests - [Test] public void OnEnd_WhenSamplerReturnsFalse_ShouldNotModifyAttributes() { @@ -27,13 +22,9 @@ public void OnEnd_WhenSamplerReturnsFalse_ShouldNotModifyAttributes() 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] @@ -52,13 +43,9 @@ public void OnEnd_WhenSamplerReturnsTrue_WithNoAttributes_ShouldNotModifyRecord( 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] @@ -211,7 +198,5 @@ public void OnEnd_WhenSamplerHasEmptyAttributes_ShouldNotAddAttributes() }), Is.True); } - - #endregion } } diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs index 62f7f19523..04a6b49f53 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/SamplingTraceExporterTests.cs @@ -1,10 +1,7 @@ -using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using LaunchDarkly.Observability.Sampling; using NUnit.Framework; -using OpenTelemetry.Logs; namespace LaunchDarkly.Observability.Test { @@ -16,7 +13,7 @@ public void SampleActivities_WhenSamplingDisabled_ShouldReturnAllActivities() { // Arrange var sampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary(), + new Dictionary(), enabled: false ); @@ -46,7 +43,7 @@ public void SampleActivities_WhenSpansAreFilteredOut_ShouldReturnOnlySelectedSpa }; var sampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [activities[0].SpanId.ToString()] = true, // Include [activities[1].SpanId.ToString()] = false, // Exclude @@ -76,7 +73,7 @@ public void SampleActivities_WhenParentSpanIsFilteredOut_ShouldAlsoFilterOutChil var activities = new[] { parentActivity, childActivity, independentActivity }; var sampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [parentActivity.SpanId.ToString()] = false, // Parent filtered out [childActivity.SpanId.ToString()] = true, // Child would be included but parent isn't @@ -104,7 +101,7 @@ public void SampleActivities_WhenNoActivitiesPassSampling_ShouldReturnEmptyList( }; var sampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [activities[0].SpanId.ToString()] = false, [activities[1].SpanId.ToString()] = false @@ -128,7 +125,7 @@ public void SampleActivities_WhenSamplingAttributesAreAdded_ShouldIncludeThemInA }; var sampler = TestSamplerHelper.CreateMockSampler( - spanSampleResults: new Dictionary + new Dictionary { [activities[0].SpanId.ToString()] = 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 index 6abebcee7d..2786ce20a2 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestActivityHelper.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestActivityHelper.cs @@ -42,4 +42,4 @@ internal static Activity CreateTestActivity(string name, string parentSpanId = n return activity; } } -} \ No newline at end of file +} diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs index 63340d5114..7ca0b56e20 100644 --- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs +++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/TestSamplerHelper.cs @@ -27,10 +27,10 @@ internal static IExportSampler CreateMockSampler( /// private class MockSampler : IExportSampler { - private readonly Dictionary _spanSampleResults; - private readonly bool _shouldSampleLogs; private readonly Dictionary _attributesToAdd; private readonly bool _enabled; + private readonly bool _shouldSampleLogs; + private readonly Dictionary _spanSampleResults; public MockSampler( Dictionary spanSampleResults, @@ -76,4 +76,4 @@ public bool IsSamplingEnabled() } } } -} \ No newline at end of file +} From e3ab9bc488aa6e33e65dc1f0dbd10fc316e1e917 Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Thu, 14 Aug 2025 16:11:45 -0700 Subject: [PATCH 7/7] Use the log helper in the custom sampler test. --- .../CustomSamplerTests.cs | 25 +++---------------- 1 file changed, 3 insertions(+), 22 deletions(-) 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 6aa11999df..628a012fc9 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);