.NET 10 changed a small tracing rule that can quietly invalidate a custom sampler. With .NET 10 ActivitySamplingResult PropagationData, a child activity no longer becomes Recorded just because its parent carries the recorded flag. The trace identity still flows, but the local sampling decision now wins.
I treat that decision as a contract worth testing. A collector is not required to reproduce it, and an exporter can actually hide the important part behind more configuration. A fixed ActivityContext, one ActivityListener, and a few assertions are enough.
Why .NET 10 ActivitySamplingResult PropagationData changed
ActivitySource.StartActivity only creates an activity when a registered listener asks for one. The listener's sampling result also says how much data the activity should collect.
The relevant choices are:
-
None: do not create the activity. -
PropagationData: create it with propagation state, but do not request enrichment or recording. -
AllData: request tags, links, and events without settingRecorded. -
AllDataAndRecorded: request enrichment and set the recorded flag.
Before .NET 10, PropagationData had an exception to that table. If the parent was recorded, the child also became recorded. Microsoft changed this because the inherited flag did not match the sampling result or the OpenTelemetry contract. The .NET 10 compatibility note now states that a PropagationData child has both Recorded == false and IsAllDataRequested == false, even under a recorded parent.
That distinction matters in custom listeners. Recorded controls the W3C recorded bit propagated downstream. IsAllDataRequested tells instrumentation whether detailed data should be attached. They answer different questions, so forcing one does not automatically enable the other. The ActivitySamplingResult API documentation is a useful compact reference for those four choices.
Reproduce the Recorded flag in one process
I start with a listener whose decision is explicit and a remote parent whose IDs are fixed test data:
var decision = ActivitySamplingResult.PropagationData;
using var source = new ActivitySource("ActivityPropagationSampling", "1.0.0");
using var listener = new ActivityListener
{
ShouldListenTo = candidate => candidate.Name == source.Name,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => decision
};
ActivitySource.AddActivityListener(listener);
var parent = new ActivityContext(
ActivityTraceId.CreateFromString("11111111111111111111111111111111"),
ActivitySpanId.CreateFromString("2222222222222222"),
ActivityTraceFlags.Recorded,
traceState: null,
isRemote: true);
using var child = source.StartActivity(
"receive-message",
ActivityKind.Consumer,
parent);
The important assertions are about the contract, not generated identifiers or wall-clock timing:
Debug.Assert(child is not null);
Debug.Assert(child.TraceId == parent.TraceId);
Debug.Assert(child.ParentSpanId == parent.SpanId);
Debug.Assert(child.Recorded is false);
Debug.Assert(child.IsAllDataRequested is false);
The child exists and continues the trace, which is exactly what PropagationData requests. It simply does not claim that this process chose to record or enrich it.
The complete sample on main turns these checks into a deterministic console verifier. It runs without credentials, network calls, model calls, an OpenTelemetry package, or a collector. The merged pull request also records the validation commands and results.
Run it with:
dotnet restore
dotnet format --verify-no-changes --no-restore
dotnet build -c Release --no-restore
dotnet run -c Release --no-build
The verifier ends with PASS: 10/10 checks. Five repeated runs produced byte-identical output in the sample validation, but I am not presenting that as a performance benchmark. It only proves that the fixture itself is stable.
Choose the sampling result deliberately
If I only need trace identity and baggage to cross a boundary, PropagationData is still the right answer. Changing it to AllDataAndRecorded merely to preserve pre-.NET 10 behavior can increase the amount of telemetry collected.
If the listener truly intends to record and enrich the activity, I return AllDataAndRecorded and test both flags. If I need the old recorded bit temporarily, Microsoft's documented compatibility measure is explicit:
child.ActivityTraceFlags |= ActivityTraceFlags.Recorded;
That line makes child.Recorded true and propagates the bit downstream. It does not make IsAllDataRequested true. Code that sets tags or events based on that property will still skip enrichment, so this is a narrow bridge rather than a replacement sampling policy.
I also keep the framework baseline visible. The sample was verified on the stable .NET 10.0.11 runtime included with SDK 10.0.303; Microsoft's 10.0.11 release notes list that supported SDK/runtime pairing.
Limits and when not to change anything
This behavior targets code that directly implements ActivityListener.Sample and returns PropagationData. Microsoft notes that the default OpenTelemetry .NET parent-based sampler is not affected. If that is your setup, do not add a flag override for a problem you do not have.
The sample also stops at the in-process contract. It does not prove exporter batching, collector sampling, backend retention, or billing behavior. Those belong in separate integration checks because they depend on the telemetry stack you deploy.
For custom samplers, though, this small test catches the exact upgrade boundary without external infrastructure. How are you regression-testing sampling decisions in your tracing code?
Happy coding!
Top comments (2)
This is a great example of why framework upgrades should be treated as behavioral contract changes, not just API compatibility checks.
The distinction you highlighted between Recorded and IsAllDataRequested is especially important. A custom ActivityListener that implicitly relied on the old parent-recorded behavior could silently change telemetry semantics after moving to .NET 10 while still producing perfectly valid-looking traces.
I particularly like the deterministic testing approach: fixed ActivityContext + explicit sampling decision + assertions on trace identity and sampling state. That isolates the runtime behavior from exporters, collectors, backend sampling, and network infrastructure, making the regression much easier to diagnose.
One additional test I’d consider is a small matrix covering:
recorded vs. non-recorded parent
local vs. remote parent
all four ActivitySamplingResult values
Recorded and IsAllDataRequested independently
downstream ActivityContext propagation
That gives custom instrumentation a clear contract and makes future runtime upgrades much safer.
The broader lesson is valuable: trace propagation, recording, and enrichment are related, but they are not interchangeable guarantees. Testing those properties independently prevents subtle telemetry regressions that can otherwise remain invisible until production.
If you're working on more .NET/OpenTelemetry infrastructure or reliability-focused tooling, I'd be interested in exchanging ideas. Our small Canada-based remote development team works with developers internationally on long-term engineering projects.
I do not write .NET, but this failure shape I know too well. Silent change in what gets recorded is worst kind, because nothing errors and dashboards stay green, I lost six months to exactly this once. Beside the matrix Kane suggests, I would add one dumb canary, a trace you expect to always arrive, with alert when it stops coming. Absence is the thing almost nobody writes a test for.