Navigating the Shift: How .NET 10 Redefines Activity Sampling and Breaks Custom Propagation Rules

Share
Navigating the Shift: How .NET 10 Redefines Activity Sampling and Breaks Custom Propagation Rules

Executive Overview

The release of .NET 10 brings a subtle yet profound shift to the foundational diagnostics and tracing layers of the runtime. For years, enterprise architectures relying on custom observability tooling, proprietary monitoring daemons, and low-level diagnostic listeners have operated under a predictable set of assumptions regarding distributed context propagation. Chief among these was an implicit inheritance model: if an incoming parent activity possessed the Recorded flag, any child activity spawned beneath it would automatically inherit that status, regardless of local sampling decisions.

With .NET 10, that long-standing behavioral rule has been permanently altered. Under the updated mechanics governing ActivitySamplingResult.PropagationData, child activities no longer morph into a Recorded state simply because their structural parent carried the recorded flag from upstream. While the underlying trace identity, span context, and baggage continue to flow seamlessly across process and network boundaries, the local sampling decision now holds absolute sovereignty.

This change brings .NET’s native diagnostics machinery into closer alignment with the strict OpenTelemetry specification, but it carries potential hazards for unwary engineering teams. Custom samplers that once relied on inherited parent states to drive downstream data capture may quietly invalidate their own tracing contracts, resulting in dropped spans, unpopulated metrics, and blind spots in distributed debugging.

This technical report investigates the mechanics of this breaking change, explores the architectural rationale behind Microsoft’s decision, provides a concrete blueprint for local regression testing, and outlines strategic pathways for developers adjusting their codebases to .NET 10.


Detailed Chronology & Mechanics of the Change

To understand the weight of the .NET 10 modification, one must examine how tracing data has historically moved through the execution pipeline of the common language runtime (CLR).

The Mechanics of ActivitySource and Listeners

When an application invokes ActivitySource.StartActivity, the runtime does not immediately allocate an arbitrary telemetry object. Instead, it queries any registered ActivityListener instances via the ShouldListenTo predicate. If a listener expresses interest in the specified source, its Sample callback is invoked. This callback must evaluate the incoming context and return an ActivitySamplingResult enum value, which dictates how much data the new activity should gather.

Before .NET 10, the sampling matrix featured four primary states:

  1. None: No data is collected, and no activity is created.
  2. PropagationData: Trace identity (TraceID, SpanID, and trace flags) is propagated, but no local data is collected or recorded.
  3. DataAndRecord (or its modern equivalent variations): Both trace identity and local data are collected, and the activity is marked as recorded.
  4. The Exception Case (PropagationData under a Recorded Parent): If a listener returned PropagationData, but the parent activity featured the Recorded trace flag (ActivityTraceFlags.Recorded), the runtime overrode the listener’s explicit decision. It forcibly promoted the child activity to a recorded state.

The OpenTelemetry Alignment

This automatic promotion mechanism created a chronic architectural mismatch. The inherited flag often contradicted the explicit return value of custom samplers or violated the explicit OpenTelemetry contract regarding sampling separation.

Specifically, the OpenTelemetry specification draws a hard line between two distinct concepts:

  • Recorded: A boolean bit that controls whether the W3C trace context propagates the recorded flag downstream to subsequent services.
  • IsAllDataRequested: A local flag that tells instrumentation libraries whether expensive internal data (such as detailed tags, events, and metrics) should be attached to the current activity.

These two properties answer fundamentally different questions. Forcing one to follow the other created scenarios where libraries spent CPU cycles collecting telemetry payloads that local samplers never intended to record, or conversely, propagated false positives downstream.

To rectify this, Microsoft updated the runtime behavior in .NET 10. According to the official .NET 10 compatibility guidelines, a child activity created under a PropagationData sampling result now strictly exhibits:

  • Recorded == false
  • IsAllDataRequested == false

This holds true even if the immediate parent activity possessed an active Recorded flag. The local sampling decision now completely wins over inherited upstream state.


Reproducing and Testing the Contract In-Process

Because this change affects low-level diagnostic contracts rather than surface-level API signatures, standard integration tests that rely on external collectors or full-stack telemetry pipelines may miss the regression entirely. To ensure absolute confidence during migration, developers must validate the behavior at the unit level using a deterministic, in-process fixture.

The In-Process Verification Pattern

By utilizing a fixed ActivityContext to simulate a remote parent, developers can instantiate an isolated ActivityListener and evaluate the resulting child activity against explicit assertions. Below is a robust implementation pattern designed to verify the .NET 10 sampling contract without external dependencies:

using System;
using System.Diagnostics;

class Program

    static void Main()
    
        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);

        // Simulate a remote parent context that carries the Recorded flag
        var parent = new ActivityContext(
            ActivityTraceId.CreateFromString("11111111111111111111111111111111"),
            ActivitySpanId.CreateFromString("2222222222222222"),
            ActivityTraceFlags.Recorded,
            traceState: null,
            isRemote: true);

        // Start the child activity under the recorded parent
        using var child = source.StartActivity(
            "receive-message",
            ActivityKind.Consumer,
            parent);

        // Enforce contractual assertions
        Debug.Assert(child is not null, "Child activity must not be null.");
        Debug.Assert(child.TraceId == parent.TraceId, "Trace ID must propagate from parent.");
        Debug.Assert(child.ParentSpanId == parent.SpanId, "Parent Span ID must match parent context.");
        Debug.Assert(child.Recorded is false, "Under .NET 10, PropagationData must not inherit Recorded == true.");
        Debug.Assert(child.IsAllDataRequested is false, "Under .NET 10, PropagationData must not request all data.");

        Console.WriteLine("PASS: All activity propagation sampling assertions verified successfully.");
    

Executing the Validation Suite

In a real-world engineering repository, this validation logic can be wrapped into a lightweight console runner or unit test project. Running the verification suite requires standard .NET SDK tooling:

dotnet restore
dotnet format --verify-no-changes --no-restore
dotnet build -c Release --no-restore
dotnet run -c Release --no-build

When executed against the stable .NET 10 runtime (such as runtime version 10.0.11 bundled with SDK 10.0.303), this test suite finishes with absolute determinism, verifying that trace identity successfully bridges the process boundary while honoring the local listener’s refusal to record detailed payload data.


Supporting Context & Metrics

To appreciate the scale at which this change impacts modern cloud-native systems, it is helpful to examine the operational metrics surrounding distributed tracing overhead and telemetry data volumes.

Telemetry Volume and Ingestion Costs

In high-throughput microservice architectures, telemetry data volume is a primary driver of cloud infrastructure expenditure. A single high-traffic API gateway can generate tens of thousands of spans per second.

  • Pre-.NET 10 Overhead: When parent-based inheritance forced child activities into a Recorded state despite local samplers opting for PropagationData, systems experienced telemetry amplification. Upstream services logging an error or triggering a sampled trace inadvertently forced downstream worker nodes to capture and serialize extensive diagnostic metadata for requests they were designed to ignore.
  • Post-.NET 10 Efficiency: By enforcing strict adherence to ActivitySamplingResult.PropagationData, .NET 10 prevents unnecessary data collection. Services that act merely as transport layers or message brokers—passing telemetry packets down the line without performing local enrichment—now correctly suppress local data collection. This reduction in unnecessary object allocation and serialization can yield measurable CPU and memory savings in high-throughput data pipelines.

The OpenTelemetry Ecosystem Split

It is vital to distinguish between custom-written samplers and standard OpenTelemetry SDK implementations.

Sampler Type Affected by .NET 10 Change? Architectural Consequence
Default OpenTelemetry .NET Sampler No Standard OTel SDK samplers explicitly manage their own decision trees and are not subject to the underlying CLR listener inheritance quirk.
Custom Hand-Rolled ActivityListener Yes Listeners returning PropagationData will immediately cease inheriting parent recorded flags, requiring explicit code adjustments if inheritance was desired.
Third-Party APM Agents Dependent Commercial APM agents that hook directly into low-level ActivityListener APIs may need updates to align with the new propagation semantics.

Official Statements and Compatibility Guidance

Microsoft’s .NET core engineering team has documented this modification thoroughly within their official compatibility notes and release documentation.

The Compatibility Directive

The core message from the official .NET 10 compatibility advisory emphasizes that the previous behavior was considered a bug regarding the strict contract of ActivitySamplingResult:

"In previous versions of .NET, returning ActivitySamplingResult.PropagationData from an ActivityListener.Sample callback would result in an activity being marked as recorded if its parent activity possessed the recorded flag. This violated the independence of local sampling decisions. In .NET 10, a PropagationData child has both Recorded == false and IsAllDataRequested == false, regardless of the parent’s state."

Implementing the Recommended Escape Hatch

For engineering teams who relied on the legacy inheritance behavior and find themselves needing a transitional bridge during migration, Microsoft provides an explicit, surgical escape hatch. If a specific workflow requires the child activity to propagate the recorded bit downstream without fully enabling local data enrichment, developers can manually update the activity trace flags immediately after creation:

child.ActivityTraceFlags |= ActivityTraceFlags.Recorded;

Crucial Caveat: Developers must exercise caution when utilizing this workaround. Setting child.ActivityTraceFlags |= ActivityTraceFlags.Recorded forces child.Recorded to true and ensures the trace bit propagates downstream. However, it does not flip IsAllDataRequested to true. Any internal instrumentation, custom tag injectors, or event loggers that condition their execution on IsAllDataRequested will still skip enrichment. Therefore, this pattern should be treated strictly as a narrow compatibility bridge rather than a wholesale replacement for a properly configured custom sampling policy.


Future Outlook & Architectural Best Practices

As the .NET ecosystem continues to mature as a premier platform for cloud-native, highly observable distributed systems, the enforcement of strict standards like OpenTelemetry becomes increasingly critical.

Actionable Recommendations for Engineering Teams

  1. Audit Custom Samplers: Review all codebases utilizing hand-rolled ActivityListener implementations. Search explicitly for references to ActivitySamplingResult.PropagationData and verify whether your services depend on inherited parent recording states.
  2. Implement In-House Unit Test Fixtures: Adopt the single-process test fixture pattern outlined in this report. Integrate these tests directly into your continuous integration (CI) pipelines to catch sampling regressions before runtime deployment.
  3. Avoid Unnecessary Flag Overrides: Refrain from blindly applying the ActivityTraceFlags.Recorded bitwise override unless your architecture specifically requires downstream propagation without local enrichment. Overusing this workaround reintroduces the telemetry noise that the .NET 10 update was designed to eliminate.
  4. Monitor Exporter and Collector Behavior: Remember that in-process tracing contracts are only the first link in the observability chain. Ensure that downstream collectors, backend batching mechanisms, and APM storage tiers are monitored for unexpected shifts in ingestion rates following your upgrade to .NET 10.

By understanding the exact mechanics of the ActivitySamplingResult.PropagationData modification and adopting rigorous verification strategies, developers can successfully modernize their .NET tracing infrastructure while maintaining absolute integrity across their distributed architectures.

Did you find this story helpful?

Share it with your friends and colleagues on social media.

Share

Leave a Comment

Your email address will not be published. Required fields are marked *