diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs
new file mode 100644
index 000000000..f28d64c66
--- /dev/null
+++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingLogProcessor.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+using LaunchDarkly.Observability.Sampling;
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+
+namespace LaunchDarkly.Observability.Otel
+{
+ ///
+ /// In dotnet logs cannot be sampled at export time because the log exporter cannot be effectively
+ /// wrapper. The log exporter is a sealed class, which prevents inheritance, and it also has
+ /// internal methods which are accessed by other otel components. These internal methods mean
+ /// that it is not possible to use composition and delegate to the base exporter.
+ ///
+ internal class SamplingLogProcessor : BaseProcessor
+ {
+ private readonly IExportSampler _sampler;
+
+ public SamplingLogProcessor(IExportSampler sampler)
+ {
+ _sampler = sampler;
+ }
+
+ public override void OnEnd(LogRecord data)
+ {
+ var res = _sampler.SampleLog(data);
+ if (!res.Sample) return;
+ if (res.Attributes != null && res.Attributes.Count > 0)
+ {
+ var combinedAttributes = new List>(res.Attributes);
+ if (data.Attributes != null) combinedAttributes.AddRange(data.Attributes);
+
+ data.Attributes = combinedAttributes;
+ }
+
+ base.OnEnd(data);
+ }
+ }
+}
diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs
new file mode 100644
index 000000000..2abffea0e
--- /dev/null
+++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Otel/SamplingTraceExporter.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using LaunchDarkly.Observability.Sampling;
+using OpenTelemetry;
+using OpenTelemetry.Exporter;
+
+namespace LaunchDarkly.Observability.Otel
+{
+ ///
+ /// Custom trace exporter that applies sampling before exporting
+ ///
+ internal class SamplingTraceExporter : OtlpTraceExporter
+ {
+ private readonly IExportSampler _sampler;
+
+ public SamplingTraceExporter(IExportSampler sampler, OtlpExporterOptions options) : base(options)
+ {
+ _sampler = sampler;
+ }
+
+ public override ExportResult Export(in Batch batch)
+ {
+ if (!_sampler.IsSamplingEnabled()) return base.Export(batch);
+
+ // Convert batch to enumerable and use the new hierarchical sampling logic
+ var activities = new List();
+ foreach (var activity in batch) activities.Add(activity);
+ var sampledActivities = SampleSpans.SampleActivities(activities, _sampler);
+
+ if (sampledActivities.Count == 0)
+ return ExportResult.Success;
+
+ // Create a new batch with only the sampled activities
+ using (var sampledBatch = new Batch(sampledActivities.ToArray(), sampledActivities.Count))
+ {
+ return base.Export(sampledBatch);
+ }
+ }
+ }
+}
diff --git a/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs
new file mode 100644
index 000000000..842faaa0e
--- /dev/null
+++ b/sdk/@launchdarkly/observability-dotnet/src/LaunchDarkly.Observability/Sampling/SampleSpans.cs
@@ -0,0 +1,76 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+
+namespace LaunchDarkly.Observability.Sampling
+{
+ ///
+ /// Utilities for sampling spans including hierarchical span sampling
+ ///
+ internal static class SampleSpans
+ {
+ ///
+ /// Sample spans with hierarchical logic that removes children of sampled-out spans
+ ///
+ /// Collection of activities to sample
+ /// The sampler to use for sampling decisions
+ /// List of sampled activities
+ public static List SampleActivities(IEnumerable activities, IExportSampler sampler)
+ {
+ if (!sampler.IsSamplingEnabled()) return activities.ToList();
+
+ var omittedSpanIds = new List();
+ var activityById = new Dictionary();
+ var childrenByParentId = new Dictionary>();
+
+ // First pass: sample items which are directly impacted by a sampling decision
+ // and build a map of children spans by parent span id
+ foreach (var activity in activities)
+ {
+ var spanId = activity.SpanId.ToString();
+
+ // Build parent-child relationship map
+ if (activity.ParentSpanId != default)
+ {
+ var parentSpanId = activity.ParentSpanId.ToString();
+ if (!childrenByParentId.ContainsKey(parentSpanId))
+ childrenByParentId[parentSpanId] = new List();
+
+ childrenByParentId[parentSpanId].Add(spanId);
+ }
+
+ // Sample the span
+ var sampleResult = sampler.SampleSpan(activity);
+ if (sampleResult.Sample)
+ {
+ if (sampleResult.Attributes != null && sampleResult.Attributes.Count > 0)
+ foreach (var attr in sampleResult.Attributes)
+ activity.SetTag(attr.Key, attr.Value);
+
+ activityById[spanId] = activity;
+ }
+ else
+ {
+ omittedSpanIds.Add(spanId);
+ }
+ }
+
+ // Find all children of spans that have been sampled out and remove them
+ // Repeat until there are no more children to remove
+ while (omittedSpanIds.Count > 0)
+ {
+ var spanId = omittedSpanIds[0];
+ omittedSpanIds.RemoveAt(0);
+
+ if (!childrenByParentId.TryGetValue(spanId, out var affectedSpans)) continue;
+ foreach (var spanIdToRemove in affectedSpans)
+ {
+ activityById.Remove(spanIdToRemove);
+ omittedSpanIds.Add(spanIdToRemove);
+ }
+ }
+
+ return activityById.Values.ToList();
+ }
+ }
+}
diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs
index 6aa11999d..628a012fc 100644
--- a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs
+++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs
@@ -302,17 +302,6 @@ public void LogSamplingTests(object oScenario)
sampler.SetConfig(scenario.SamplingConfig);
Assert.That(sampler.IsSamplingEnabled(), Is.True);
- var services = new ServiceCollection();
- var records = new List();
- services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); },
- options => { options.IncludeScopes = true; });
- Console.WriteLine(services);
- var provider = services.BuildServiceProvider();
- var loggerProvider = provider.GetService();
- var withScope = loggerProvider as ISupportExternalScope;
- Assert.That(withScope, Is.Not.Null);
- withScope.SetScopeProvider(new LoggerExternalScopeProvider());
- var logger = loggerProvider.CreateLogger("test");
var properties = new Dictionary();
foreach (var inputLogAttribute in scenario.InputLog.Attributes)
@@ -320,21 +309,13 @@ public void LogSamplingTests(object oScenario)
properties.Add(inputLogAttribute.Key, GetJsonRawValue(inputLogAttribute));
}
- using (logger.BeginScope(properties))
- {
- logger.Log(SeverityTextToLogLevel(scenario.InputLog.SeverityText),
- new EventId(), properties, null,
- (objects, exception) => scenario.InputLog.Message ?? "");
- }
-
- Console.WriteLine(records);
-
- var record = records.First();
+ var record = LogRecordHelper.CreateTestLogRecord(SeverityTextToLogLevel(scenario.InputLog.SeverityText),
+ scenario.InputLog.Message ?? "", properties);
Assert.Multiple(() =>
{
// Cursory check that the record is formed properly.
Assert.That(scenario.InputLog.Message ?? "", Is.EqualTo(record.Body));
- Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count));
+ Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count ?? 0));
});
var res = sampler.SampleLog(record);
diff --git a/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs
new file mode 100644
index 000000000..254374856
--- /dev/null
+++ b/sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/LogRecordHelper.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NUnit.Framework;
+using OpenTelemetry.Logs;
+
+namespace LaunchDarkly.Observability.Test
+{
+ public static class LogRecordHelper
+ {
+ ///
+ /// Creates a LogRecord for testing
+ ///
+ public static LogRecord CreateTestLogRecord(LogLevel level, string message,
+ Dictionary attributes = null)
+ {
+ var services = new ServiceCollection();
+ var records = new List();
+
+ services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); });
+
+ var provider = services.BuildServiceProvider();
+ var loggerProvider = provider.GetService();
+ var withScope = loggerProvider as ISupportExternalScope;
+ Assert.That(withScope, Is.Not.Null);
+ withScope.SetScopeProvider(new LoggerExternalScopeProvider());
+ var logger = loggerProvider.CreateLogger("test");
+
+ // Log with attributes if provided - use the same pattern as CustomSamplerTests
+ if (attributes != null && attributes.Count > 0)
+ logger.Log(level, new EventId(), attributes, null,
+ (objects, exception) => message);
+ else
+ logger.Log