Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using LaunchDarkly.Observability.Sampling;
using OpenTelemetry;
using OpenTelemetry.Logs;

namespace LaunchDarkly.Observability.Otel
{
/// <summary>
/// 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.
/// </summary>
internal class SamplingLogProcessor : BaseProcessor<LogRecord>
{
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<KeyValuePair<string, object>>(res.Attributes);
if (data.Attributes != null) combinedAttributes.AddRange(data.Attributes);

data.Attributes = combinedAttributes;
}

base.OnEnd(data);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Diagnostics;
using LaunchDarkly.Observability.Sampling;
using OpenTelemetry;
using OpenTelemetry.Exporter;

namespace LaunchDarkly.Observability.Otel
{
/// <summary>
/// Custom trace exporter that applies sampling before exporting
/// </summary>
internal class SamplingTraceExporter : OtlpTraceExporter
{
private readonly IExportSampler _sampler;

public SamplingTraceExporter(IExportSampler sampler, OtlpExporterOptions options) : base(options)
{
_sampler = sampler;
}

public override ExportResult Export(in Batch<Activity> batch)
{
if (!_sampler.IsSamplingEnabled()) return base.Export(batch);

// Convert batch to enumerable and use the new hierarchical sampling logic
var activities = new List<Activity>();
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<Activity>(sampledActivities.ToArray(), sampledActivities.Count))
{
return base.Export(sampledBatch);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

namespace LaunchDarkly.Observability.Sampling
{
/// <summary>
/// Utilities for sampling spans including hierarchical span sampling
/// </summary>
internal static class SampleSpans
{
/// <summary>
/// Sample spans with hierarchical logic that removes children of sampled-out spans
/// </summary>
/// <param name="activities">Collection of activities to sample</param>
/// <param name="sampler">The sampler to use for sampling decisions</param>
/// <returns>List of sampled activities</returns>
public static List<Activity> SampleActivities(IEnumerable<Activity> activities, IExportSampler sampler)
{
if (!sampler.IsSamplingEnabled()) return activities.ToList();

var omittedSpanIds = new List<string>();
var activityById = new Dictionary<string, Activity>();
var childrenByParentId = new Dictionary<string, List<string>>();

// 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<string>();

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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -302,39 +302,20 @@ public void LogSamplingTests(object oScenario)
sampler.SetConfig(scenario.SamplingConfig);

Assert.That(sampler.IsSamplingEnabled(), Is.True);
var services = new ServiceCollection();
var records = new List<LogRecord>();
services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); },
options => { options.IncludeScopes = true; });
Console.WriteLine(services);
var provider = services.BuildServiceProvider();
var loggerProvider = provider.GetService<ILoggerProvider>();
var withScope = loggerProvider as ISupportExternalScope;
Assert.That(withScope, Is.Not.Null);
withScope.SetScopeProvider(new LoggerExternalScopeProvider());
var logger = loggerProvider.CreateLogger("test");

var properties = new Dictionary<string, object>();
foreach (var inputLogAttribute in scenario.InputLog.Attributes)
{
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Creates a LogRecord for testing
/// </summary>
public static LogRecord CreateTestLogRecord(LogLevel level, string message,
Dictionary<string, object> attributes = null)
{
var services = new ServiceCollection();
var records = new List<LogRecord>();

services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); });

var provider = services.BuildServiceProvider();
var loggerProvider = provider.GetService<ILoggerProvider>();
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<object>(level, new EventId(), null, null,
(objects, exception) => message);

return records.First();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using LaunchDarkly.Observability.Sampling;
using NUnit.Framework;

namespace LaunchDarkly.Observability.Test
{
[TestFixture]
public class SampleSpansTests
{
[Test]
public void SampleActivities_ShouldRemoveSpansThatAreNotSampled()
{
var activities = new List<Activity>
{
TestActivityHelper.CreateTestActivity("span-1"), // Root span - sampled
TestActivityHelper.CreateTestActivity("span-2") // Root span - not sampled
};

var span1 = activities[0];
var span2 = activities[1];

// We need to mock the span IDs in our sampler
var samplerWithRealIds = TestSamplerHelper.CreateMockSampler(
new Dictionary<string, bool>
{
[span1.SpanId.ToString()] = true,
[span2.SpanId.ToString()] = false
},
attributesToAdd: new Dictionary<string, object> { ["samplingRatio"] = 2 }
);

// Act
var sampledActivities = SampleSpans.SampleActivities(activities, samplerWithRealIds);

// Assert
Assert.That(sampledActivities.Count, Is.EqualTo(1));
Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("span-1"));
Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)),
Is.True);
}

[Test]
public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled()
{
// Arrange - Create span hierarchy with parent -> child -> grandchild
var parentActivity = TestActivityHelper.CreateTestActivity("parent");
var childActivity = TestActivityHelper.CreateTestActivity("child", parentActivity.SpanId.ToString());
var grandchildActivity =
TestActivityHelper.CreateTestActivity("grandchild", childActivity.SpanId.ToString());
var rootActivity = TestActivityHelper.CreateTestActivity("root");

var activities = new List<Activity>
{
parentActivity,
childActivity,
grandchildActivity,
rootActivity
};

var mockSampler = TestSamplerHelper.CreateMockSampler(
new Dictionary<string, bool>
{
[parentActivity.SpanId.ToString()] = false, // Parent not sampled
[childActivity.SpanId.ToString()] = true, // Child would be sampled but parent isn't
[grandchildActivity.SpanId.ToString()] = true, // Grandchild would be sampled but parent isn't
[rootActivity.SpanId.ToString()] = true // Root sampled
}
);

// Act
var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler);

// Assert
Assert.That(sampledActivities.Count, Is.EqualTo(1));
Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("root"));
}

[Test]
public void SampleActivities_ShouldNotApplySamplingWhenSamplingIsDisabled()
{
// Arrange
var mockSampler = TestSamplerHelper.CreateMockSampler(
new Dictionary<string, bool>(), // Empty results
enabled: false // Sampling disabled
);

var activities = new List<Activity>
{
TestActivityHelper.CreateTestActivity("span-1"),
TestActivityHelper.CreateTestActivity("span-2")
};

// Act
var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler);

// Assert
Assert.That(sampledActivities.Count, Is.EqualTo(2));
Assert.That(sampledActivities, Is.EqualTo(activities));
}

[Test]
public void SampleActivities_ShouldApplySamplingAttributesToSampledSpans()
{
// Arrange
var activities = new List<Activity>
{
TestActivityHelper.CreateTestActivity("span-1"),
TestActivityHelper.CreateTestActivity("span-2")
};

var mockSampler = TestSamplerHelper.CreateMockSampler(
new Dictionary<string, bool>
{
[activities[0].SpanId.ToString()] = true,
[activities[1].SpanId.ToString()] = true
},
attributesToAdd: new Dictionary<string, object> { ["samplingRatio"] = 2 }
);

// Act
var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler);

// Assert
Assert.That(sampledActivities.Count, Is.EqualTo(2));
Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)),
Is.True);
Assert.That(sampledActivities[1].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)),
Is.True);
}
}
}
Loading
Loading