Skip to content

Commit 2431886

Browse files
authored
feat: Add export/processor samplers. (#178)
## Summary Adds, but does not yet use, samplers. ## How did you test this change? Unit tests. ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? -->
1 parent de74b20 commit 2431886

File tree

10 files changed

+801
-22
lines changed

10 files changed

+801
-22
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Collections.Generic;
2+
using LaunchDarkly.Observability.Sampling;
3+
using OpenTelemetry;
4+
using OpenTelemetry.Logs;
5+
6+
namespace LaunchDarkly.Observability.Otel
7+
{
8+
/// <summary>
9+
/// In dotnet logs cannot be sampled at export time because the log exporter cannot be effectively
10+
/// wrapper. The log exporter is a sealed class, which prevents inheritance, and it also has
11+
/// internal methods which are accessed by other otel components. These internal methods mean
12+
/// that it is not possible to use composition and delegate to the base exporter.
13+
/// </summary>
14+
internal class SamplingLogProcessor : BaseProcessor<LogRecord>
15+
{
16+
private readonly IExportSampler _sampler;
17+
18+
public SamplingLogProcessor(IExportSampler sampler)
19+
{
20+
_sampler = sampler;
21+
}
22+
23+
public override void OnEnd(LogRecord data)
24+
{
25+
var res = _sampler.SampleLog(data);
26+
if (!res.Sample) return;
27+
if (res.Attributes != null && res.Attributes.Count > 0)
28+
{
29+
var combinedAttributes = new List<KeyValuePair<string, object>>(res.Attributes);
30+
if (data.Attributes != null) combinedAttributes.AddRange(data.Attributes);
31+
32+
data.Attributes = combinedAttributes;
33+
}
34+
35+
base.OnEnd(data);
36+
}
37+
}
38+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics;
3+
using LaunchDarkly.Observability.Sampling;
4+
using OpenTelemetry;
5+
using OpenTelemetry.Exporter;
6+
7+
namespace LaunchDarkly.Observability.Otel
8+
{
9+
/// <summary>
10+
/// Custom trace exporter that applies sampling before exporting
11+
/// </summary>
12+
internal class SamplingTraceExporter : OtlpTraceExporter
13+
{
14+
private readonly IExportSampler _sampler;
15+
16+
public SamplingTraceExporter(IExportSampler sampler, OtlpExporterOptions options) : base(options)
17+
{
18+
_sampler = sampler;
19+
}
20+
21+
public override ExportResult Export(in Batch<Activity> batch)
22+
{
23+
if (!_sampler.IsSamplingEnabled()) return base.Export(batch);
24+
25+
// Convert batch to enumerable and use the new hierarchical sampling logic
26+
var activities = new List<Activity>();
27+
foreach (var activity in batch) activities.Add(activity);
28+
var sampledActivities = SampleSpans.SampleActivities(activities, _sampler);
29+
30+
if (sampledActivities.Count == 0)
31+
return ExportResult.Success;
32+
33+
// Create a new batch with only the sampled activities
34+
using (var sampledBatch = new Batch<Activity>(sampledActivities.ToArray(), sampledActivities.Count))
35+
{
36+
return base.Export(sampledBatch);
37+
}
38+
}
39+
}
40+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics;
3+
using System.Linq;
4+
5+
namespace LaunchDarkly.Observability.Sampling
6+
{
7+
/// <summary>
8+
/// Utilities for sampling spans including hierarchical span sampling
9+
/// </summary>
10+
internal static class SampleSpans
11+
{
12+
/// <summary>
13+
/// Sample spans with hierarchical logic that removes children of sampled-out spans
14+
/// </summary>
15+
/// <param name="activities">Collection of activities to sample</param>
16+
/// <param name="sampler">The sampler to use for sampling decisions</param>
17+
/// <returns>List of sampled activities</returns>
18+
public static List<Activity> SampleActivities(IEnumerable<Activity> activities, IExportSampler sampler)
19+
{
20+
if (!sampler.IsSamplingEnabled()) return activities.ToList();
21+
22+
var omittedSpanIds = new List<string>();
23+
var activityById = new Dictionary<string, Activity>();
24+
var childrenByParentId = new Dictionary<string, List<string>>();
25+
26+
// First pass: sample items which are directly impacted by a sampling decision
27+
// and build a map of children spans by parent span id
28+
foreach (var activity in activities)
29+
{
30+
var spanId = activity.SpanId.ToString();
31+
32+
// Build parent-child relationship map
33+
if (activity.ParentSpanId != default)
34+
{
35+
var parentSpanId = activity.ParentSpanId.ToString();
36+
if (!childrenByParentId.ContainsKey(parentSpanId))
37+
childrenByParentId[parentSpanId] = new List<string>();
38+
39+
childrenByParentId[parentSpanId].Add(spanId);
40+
}
41+
42+
// Sample the span
43+
var sampleResult = sampler.SampleSpan(activity);
44+
if (sampleResult.Sample)
45+
{
46+
if (sampleResult.Attributes != null && sampleResult.Attributes.Count > 0)
47+
foreach (var attr in sampleResult.Attributes)
48+
activity.SetTag(attr.Key, attr.Value);
49+
50+
activityById[spanId] = activity;
51+
}
52+
else
53+
{
54+
omittedSpanIds.Add(spanId);
55+
}
56+
}
57+
58+
// Find all children of spans that have been sampled out and remove them
59+
// Repeat until there are no more children to remove
60+
while (omittedSpanIds.Count > 0)
61+
{
62+
var spanId = omittedSpanIds[0];
63+
omittedSpanIds.RemoveAt(0);
64+
65+
if (!childrenByParentId.TryGetValue(spanId, out var affectedSpans)) continue;
66+
foreach (var spanIdToRemove in affectedSpans)
67+
{
68+
activityById.Remove(spanIdToRemove);
69+
omittedSpanIds.Add(spanIdToRemove);
70+
}
71+
}
72+
73+
return activityById.Values.ToList();
74+
}
75+
}
76+
}

sdk/@launchdarkly/observability-dotnet/test/LaunchDarkly.Observability.Tests/CustomSamplerTests.cs

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -302,39 +302,20 @@ public void LogSamplingTests(object oScenario)
302302
sampler.SetConfig(scenario.SamplingConfig);
303303

304304
Assert.That(sampler.IsSamplingEnabled(), Is.True);
305-
var services = new ServiceCollection();
306-
var records = new List<LogRecord>();
307-
services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); },
308-
options => { options.IncludeScopes = true; });
309-
Console.WriteLine(services);
310-
var provider = services.BuildServiceProvider();
311-
var loggerProvider = provider.GetService<ILoggerProvider>();
312-
var withScope = loggerProvider as ISupportExternalScope;
313-
Assert.That(withScope, Is.Not.Null);
314-
withScope.SetScopeProvider(new LoggerExternalScopeProvider());
315-
var logger = loggerProvider.CreateLogger("test");
316305

317306
var properties = new Dictionary<string, object>();
318307
foreach (var inputLogAttribute in scenario.InputLog.Attributes)
319308
{
320309
properties.Add(inputLogAttribute.Key, GetJsonRawValue(inputLogAttribute));
321310
}
322311

323-
using (logger.BeginScope(properties))
324-
{
325-
logger.Log(SeverityTextToLogLevel(scenario.InputLog.SeverityText),
326-
new EventId(), properties, null,
327-
(objects, exception) => scenario.InputLog.Message ?? "");
328-
}
329-
330-
Console.WriteLine(records);
331-
332-
var record = records.First();
312+
var record = LogRecordHelper.CreateTestLogRecord(SeverityTextToLogLevel(scenario.InputLog.SeverityText),
313+
scenario.InputLog.Message ?? "", properties);
333314
Assert.Multiple(() =>
334315
{
335316
// Cursory check that the record is formed properly.
336317
Assert.That(scenario.InputLog.Message ?? "", Is.EqualTo(record.Body));
337-
Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count));
318+
Assert.That(scenario.InputLog.Attributes?.Count ?? 0, Is.EqualTo(record.Attributes?.Count ?? 0));
338319
});
339320

340321
var res = sampler.SampleLog(record);
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Microsoft.Extensions.Logging;
5+
using NUnit.Framework;
6+
using OpenTelemetry.Logs;
7+
8+
namespace LaunchDarkly.Observability.Test
9+
{
10+
public static class LogRecordHelper
11+
{
12+
/// <summary>
13+
/// Creates a LogRecord for testing
14+
/// </summary>
15+
public static LogRecord CreateTestLogRecord(LogLevel level, string message,
16+
Dictionary<string, object> attributes = null)
17+
{
18+
var services = new ServiceCollection();
19+
var records = new List<LogRecord>();
20+
21+
services.AddOpenTelemetry().WithLogging(logging => { logging.AddInMemoryExporter(records); });
22+
23+
var provider = services.BuildServiceProvider();
24+
var loggerProvider = provider.GetService<ILoggerProvider>();
25+
var withScope = loggerProvider as ISupportExternalScope;
26+
Assert.That(withScope, Is.Not.Null);
27+
withScope.SetScopeProvider(new LoggerExternalScopeProvider());
28+
var logger = loggerProvider.CreateLogger("test");
29+
30+
// Log with attributes if provided - use the same pattern as CustomSamplerTests
31+
if (attributes != null && attributes.Count > 0)
32+
logger.Log(level, new EventId(), attributes, null,
33+
(objects, exception) => message);
34+
else
35+
logger.Log<object>(level, new EventId(), null, null,
36+
(objects, exception) => message);
37+
38+
return records.First();
39+
}
40+
}
41+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
using System.Collections.Generic;
2+
using System.Diagnostics;
3+
using System.Linq;
4+
using LaunchDarkly.Observability.Sampling;
5+
using NUnit.Framework;
6+
7+
namespace LaunchDarkly.Observability.Test
8+
{
9+
[TestFixture]
10+
public class SampleSpansTests
11+
{
12+
[Test]
13+
public void SampleActivities_ShouldRemoveSpansThatAreNotSampled()
14+
{
15+
var activities = new List<Activity>
16+
{
17+
TestActivityHelper.CreateTestActivity("span-1"), // Root span - sampled
18+
TestActivityHelper.CreateTestActivity("span-2") // Root span - not sampled
19+
};
20+
21+
var span1 = activities[0];
22+
var span2 = activities[1];
23+
24+
// We need to mock the span IDs in our sampler
25+
var samplerWithRealIds = TestSamplerHelper.CreateMockSampler(
26+
new Dictionary<string, bool>
27+
{
28+
[span1.SpanId.ToString()] = true,
29+
[span2.SpanId.ToString()] = false
30+
},
31+
attributesToAdd: new Dictionary<string, object> { ["samplingRatio"] = 2 }
32+
);
33+
34+
// Act
35+
var sampledActivities = SampleSpans.SampleActivities(activities, samplerWithRealIds);
36+
37+
// Assert
38+
Assert.That(sampledActivities.Count, Is.EqualTo(1));
39+
Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("span-1"));
40+
Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)),
41+
Is.True);
42+
}
43+
44+
[Test]
45+
public void SampleActivities_ShouldRemoveChildrenOfSpansThatAreNotSampled()
46+
{
47+
// Arrange - Create span hierarchy with parent -> child -> grandchild
48+
var parentActivity = TestActivityHelper.CreateTestActivity("parent");
49+
var childActivity = TestActivityHelper.CreateTestActivity("child", parentActivity.SpanId.ToString());
50+
var grandchildActivity =
51+
TestActivityHelper.CreateTestActivity("grandchild", childActivity.SpanId.ToString());
52+
var rootActivity = TestActivityHelper.CreateTestActivity("root");
53+
54+
var activities = new List<Activity>
55+
{
56+
parentActivity,
57+
childActivity,
58+
grandchildActivity,
59+
rootActivity
60+
};
61+
62+
var mockSampler = TestSamplerHelper.CreateMockSampler(
63+
new Dictionary<string, bool>
64+
{
65+
[parentActivity.SpanId.ToString()] = false, // Parent not sampled
66+
[childActivity.SpanId.ToString()] = true, // Child would be sampled but parent isn't
67+
[grandchildActivity.SpanId.ToString()] = true, // Grandchild would be sampled but parent isn't
68+
[rootActivity.SpanId.ToString()] = true // Root sampled
69+
}
70+
);
71+
72+
// Act
73+
var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler);
74+
75+
// Assert
76+
Assert.That(sampledActivities.Count, Is.EqualTo(1));
77+
Assert.That(sampledActivities[0].DisplayName, Is.EqualTo("root"));
78+
}
79+
80+
[Test]
81+
public void SampleActivities_ShouldNotApplySamplingWhenSamplingIsDisabled()
82+
{
83+
// Arrange
84+
var mockSampler = TestSamplerHelper.CreateMockSampler(
85+
new Dictionary<string, bool>(), // Empty results
86+
enabled: false // Sampling disabled
87+
);
88+
89+
var activities = new List<Activity>
90+
{
91+
TestActivityHelper.CreateTestActivity("span-1"),
92+
TestActivityHelper.CreateTestActivity("span-2")
93+
};
94+
95+
// Act
96+
var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler);
97+
98+
// Assert
99+
Assert.That(sampledActivities.Count, Is.EqualTo(2));
100+
Assert.That(sampledActivities, Is.EqualTo(activities));
101+
}
102+
103+
[Test]
104+
public void SampleActivities_ShouldApplySamplingAttributesToSampledSpans()
105+
{
106+
// Arrange
107+
var activities = new List<Activity>
108+
{
109+
TestActivityHelper.CreateTestActivity("span-1"),
110+
TestActivityHelper.CreateTestActivity("span-2")
111+
};
112+
113+
var mockSampler = TestSamplerHelper.CreateMockSampler(
114+
new Dictionary<string, bool>
115+
{
116+
[activities[0].SpanId.ToString()] = true,
117+
[activities[1].SpanId.ToString()] = true
118+
},
119+
attributesToAdd: new Dictionary<string, object> { ["samplingRatio"] = 2 }
120+
);
121+
122+
// Act
123+
var sampledActivities = SampleSpans.SampleActivities(activities, mockSampler);
124+
125+
// Assert
126+
Assert.That(sampledActivities.Count, Is.EqualTo(2));
127+
Assert.That(sampledActivities[0].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)),
128+
Is.True);
129+
Assert.That(sampledActivities[1].TagObjects.Any(t => t.Key == "samplingRatio" && t.Value.Equals(2)),
130+
Is.True);
131+
}
132+
}
133+
}

0 commit comments

Comments
 (0)