Skip to content

RUM-15138: Ignore dropped local active span in RUM-to-APM path#3406

Merged
hamorillo merged 5 commits into
developfrom
hector.morilloprieto/RUM-15138-ignore-dropped-parent
May 21, 2026
Merged

RUM-15138: Ignore dropped local active span in RUM-to-APM path#3406
hamorillo merged 5 commits into
developfrom
hector.morilloprieto/RUM-15138-ignore-dropped-parent

Conversation

@hamorillo

@hamorillo hamorillo commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

When a local manual span (created via the Trace SDK) is active but has been dropped by the trace sampler, the RUM-to-APM path (DatadogInterceptor / headerPropagationOnly=true) now ignores it and creates an independent trace with its own sampling decision. This prevents the Trace SDK's drop decision from killing RUM-APM correlation for network requests where the RUM SDK would have sampled the trace.

The fix only affects the case where the dropped parent is a local active span with no explicit parent context. Explicit parent contexts — set via Request.Builder().parentSpan(...), request tags, or inbound trace propagation headers (Datadog, W3C, B3) — are always honored regardless of their sampling priority. This preserves distributed-trace continuity when an upstream service or developer code has explicitly chosen a parent.

Motivation

When a customer creates a manual span via the Trace SDK that gets dropped (e.g., 5% trace rate), and then makes a network request through DatadogInterceptor, HBS previously propagated the drop — the RUM resource lost all trace correlation. The customer configured the RUM SDK to sample at a higher rate, but the Trace SDK's lower rate overrode it. This change lets the RUM SDK make its own independent sampling decision in that scenario, while still honoring any explicit parent the developer or upstream service has propagated.

Behavior matrix (RUM path only — canSendSpan()=false)

Parent source Priority Behavior
Local active span (tracer.activeSpan()) SAMPLER_DROP / USER_DROP Ignore parent, start fresh trace (new behavior)
Local active span SAMPLER_KEEP / USER_KEEP / UNSET Honor parent (unchanged)
Request tag (Request.Builder().parentSpan(...)) any Honor parent (unchanged)
Inbound trace headers (Datadog / W3C / B3) any, including drop Honor parent (unchanged)

The TracingInterceptor APM-only path (canSendSpan()=true) is unaffected — HBS still propagates dropped parents as before.

Implementation Notes

  • Mechanism: TracingInterceptor.buildSpan() and ApmNetworkInstrumentationExt.buildSpan() call isParentDropped(), which inspects only the local active span when no explicit parent context is present. When that active span resolves to SAMPLER_DROP or USER_DROP, ignoreActiveSpan() is called on the span builder, creating a root span with a fresh trace ID.
  • Lazy sampling resolution: A manual span backed by a PendingTrace can carry UNSET priority until propagation / inject runs the sampler. Without forcing resolution, the check would treat such a span as not-dropped, attach the network span as a child, and then watch the APM sampler propagate a DROP at inject time — exactly the scenario this code is meant to prevent. isParentDropped calls _TraceInternalProxy.setTracingSamplingPriorityIfNecessary on the active span's context before reading its priority. This is the same forcing mechanism already used in extractSamplingDecision and during inject; it is idempotent (CAS-guarded) and produces the exact decision the trace would have received later.
  • No API surface changes — both isParentDropped helpers are private.
  • Depends on PR RUM-15138: Add cross-product sampling rebasing for RUM-to-APM correlation #3402 (cross-product sampling rebasing) — the SessionRebasedSampler from that PR is what makes the independent sampling decision meaningful.
  • Related iOS work: dd-sdk-ios#2726.

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@hamorillo
hamorillo force-pushed the hector.morilloprieto/RUM-15138-ignore-dropped-parent branch from e575fc2 to 2135aec Compare April 30, 2026 08:31
@codecov-commenter

codecov-commenter commented Apr 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.94595% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.14%. Comparing base (9ef8d44) to head (d7dd316).
⚠️ Report is 16 commits behind head on develop.

Files with missing lines Patch % Lines
...datadog/android/okhttp/trace/TracingInterceptor.kt 93.55% 0 Missing and 2 partials ⚠️
...trace/internal/net/ApmNetworkInstrumentationExt.kt 94.44% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3406      +/-   ##
===========================================
+ Coverage    72.09%   72.14%   +0.05%     
===========================================
  Files          962      962              
  Lines        35440    35491      +51     
  Branches      5895     5919      +24     
===========================================
+ Hits         25548    25602      +54     
+ Misses        8274     8264      -10     
- Partials      1618     1625       +7     
Files with missing lines Coverage Δ
...ndroid/trace/internal/ApmNetworkInstrumentation.kt 62.20% <100.00%> (+1.23%) ⬆️
...android/trace/internal/DatadogPropagationHelper.kt 69.77% <100.00%> (+2.68%) ⬆️
...trace/internal/net/ApmNetworkInstrumentationExt.kt 78.57% <94.44%> (+9.61%) ⬆️
...datadog/android/okhttp/trace/TracingInterceptor.kt 83.29% <93.55%> (+1.20%) ⬆️

... and 36 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2135aec15d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}

private fun isParentDropped(tracer: DatadogTracer, explicitParent: DatadogSpanContext?): Boolean {
val priority = explicitParent?.samplingPriority ?: tracer.activeSpan()?.samplingPriority

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Force active-parent sampling before checking drops

In the RUM/headerPropagationOnly path, an active manual span normally has samplingPriority == null until propagation forces the trace sampler (CorePropagation.inject() calls setSamplingPriorityIfNecessary()). With that state this check treats the active parent as not dropped, so the network span remains its child; when updateRequest() later injects headers, the APM sampler can still set SAMPLER_DROP and propagate the dropped trace, which is the scenario this change is meant to prevent. Please force/read the active parent's sampling decision before deciding not to ignore it, and apply the same fix to the duplicated helper in ApmNetworkInstrumentationExt.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like a valid concern.

Manual spans created via the Trace SDK have samplingPriority = UNSET until propagation forces the decision (CorePropagation.inject() → setSamplingPriorityIfNecessary()), so isParentDropped was always returning false for them — defeating the purpose of this change.

Commit 647ed6d fixes this by calling _TraceInternalProxy.setTracingSamplingPriorityIfNecessary(parentContext) before reading the priority, which is the same forcing mechanism already used in extractSamplingDecision and during inject(). It's CAS-guarded and idempotent so I think it should be safe. Applied in both TracingInterceptor.isParentDropped() and ApmNetworkInstrumentationExt.isParentDropped().

@hamorillo

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 647ed6d786

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

ignoreDroppedParent: Boolean
): DatadogSpan {
val parentContext = propagationHelper.extractParentContext(this, request)
val shouldIgnoreParent = ignoreDroppedParent && isParentDropped(this, parentContext)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip stale DROP decisions when ignoring a parent

When the dropped parent was extracted from request headers or a request tag, parentContext is non-null so this branch ignores it, but DatadogSpan.sample(request) still calls propagationHelper.extractSamplingDecision(request) and rereads the same DROP decision before traceSampler can run; the OkHttp path added the same pattern. In the RUM/header-propagation path this means RUM still cannot make an independent sampling decision for dropped extracted parents, so correlation is still lost except for active-span parents with no request-carried context.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've being thinking about this comment because it's a bit complex and I think, it describes a scenario where tracing headers on the request (e.g., x-datadog-sampling-priority: 0) re-poison the sampling decision after we ignore a dropped parent.

However, our PR specifically targets the case where a local active manual span from the Trace SDK is dropped and drags down RUM network spans — in that scenario, the request carries no tracing headers, so extractSamplingDecision(request) returns null and the traceSampler runs independently as intended. If a request does carry explicit DROP headers, that represents a deliberate upstream decision about that specific trace, not the accidental inheritance from an unrelated manual span — respecting that DROP looks like is correct behavior.

Anyway, happy to listen to other opinions.

@hamorillo
hamorillo force-pushed the hector.morilloprieto/RUM-15138-decorator branch from 37c5f51 to ff4f037 Compare April 30, 2026 12:45
@hamorillo
hamorillo force-pushed the hector.morilloprieto/RUM-15138-ignore-dropped-parent branch from 647ed6d to e849395 Compare April 30, 2026 13:55
@hamorillo hamorillo changed the title RUM-15138: Ignore dropped parent span in RUM-to-APM path RUM-15138: Ignore dropped local active span in RUM-to-APM path Apr 30, 2026
@hamorillo
hamorillo marked this pull request as ready for review April 30, 2026 14:44
@hamorillo
hamorillo requested review from a team as code owners April 30, 2026 14:44
kikoveiga
kikoveiga previously approved these changes May 6, 2026
@hamorillo
hamorillo force-pushed the hector.morilloprieto/RUM-15138-decorator branch from 45062e0 to 7c7046f Compare May 14, 2026 07:09
Base automatically changed from hector.morilloprieto/RUM-15138-decorator to develop May 18, 2026 08:30
@hamorillo
hamorillo dismissed kikoveiga’s stale review May 18, 2026 08:30

The base branch was changed.

hamorillo added 2 commits May 18, 2026 12:24
The check for a dropped parent in the RUM-to-APM path read samplingPriority
on the active span without first resolving it. Manual spans created via the
Trace SDK keep their priority as UNSET until propagation/inject runs the
sampler, so the check would treat them as not-dropped, attach the network
span as a child, and then watch the APM sampler propagate a DROP at inject
time — exactly the scenario this code is meant to prevent.

isParentDropped now calls _TraceInternalProxy.setTracingSamplingPriorityIfNecessary
on the parent context before reading its priority. This is the same forcing
mechanism already used in extractSamplingDecision and during inject; it is
idempotent (CAS-guarded) and produces the exact decision the trace would
have received later.

Same fix applied in TracingInterceptor (okhttp) and ApmNetworkInstrumentationExt
(trace). Existing tests updated to stub the parent context's priority (not
just the span's), and new tests cover the "context resolves to DROP/KEEP
after forcing" cases.
@hamorillo
hamorillo force-pushed the hector.morilloprieto/RUM-15138-ignore-dropped-parent branch from e849395 to 6f2854c Compare May 18, 2026 10:27
@hamorillo
hamorillo requested review from kikoveiga and satween May 18, 2026 10:28
kikoveiga
kikoveiga previously approved these changes May 18, 2026
@satween

satween commented May 18, 2026

Copy link
Copy Markdown
Contributor

Are you sure it won't break RUM-8051? worth to discuss with @0xnm as well

val activeContext = if (explicitParent != null) null else tracer.activeSpan()?.context()
// Force resolution of the active span's sampling priority — a manual span backed
// by a PendingTrace can read UNSET until the sampler commits at inject time.
activeContext?.let { _TraceInternalProxy.setTracingSamplingPriorityIfNecessary(it) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a side-effect of the function and it's unclear from the caller side. we should highlight that this priority would be set in a function name, or (it's better) to move this logic out of this getter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I've refactored this.

Renames ignoreDroppedParent to ignoreLocalDroppedParent so the flag name matches its actual scope (only local active spans can be ignored), and lifts the setTracingSamplingPriorityIfNecessary side effect out of the predicate so it is visible at the buildSpan call site, mirroring the pattern in extractSamplingDecision. The isParentDropped helper is replaced by a pure DatadogSpanContext?.isDropped() extension.

@hamorillo hamorillo left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure it won't break RUM-8051? worth to discuss with @0xnm as well

@satween Good question. I would say that's the main goal of this PR. When there is an active span (and trace) from APM that is not sampled, RUM should ignore it and act as if the trace did not exist. Basically, it should make its own decision. This is the Android counterpart of iOS RUM-15310 (DataDog/dd-sdk-ios#2807).

In any other cases, the implementation from RUM-8051 should remain identical.

Anyway, let's wait for @0xnm's insight.

val activeContext = if (explicitParent != null) null else tracer.activeSpan()?.context()
// Force resolution of the active span's sampling priority — a manual span backed
// by a PendingTrace can read UNSET until the sampler commits at inject time.
activeContext?.let { _TraceInternalProxy.setTracingSamplingPriorityIfNecessary(it) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I've refactored this.

Comment on lines +83 to +84
// Only consult the local active span. Explicit parents (request tags or propagated
// headers) represent developer intent and must be honored regardless of priority.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it the same on iOS? The spec written by @qsellem says:

But the spans are not attached to the manually created trace and so the sampling decision is taken by the RUM SDK, not the Trace SDK

I guess there is a blind spot here, because this PR says that spans added by something like Request.Builder.addParentSpan are unaffected.

Normally, this becomes a change maybe for Cronet only then? Because in case of OkHttp active span is thread-local and this code is going to be executed from the thread created by OkHttp, if enqueue is used (so customer cannot associate its own span with this thread unless done somewhere in the upstream interceptor).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! As we've discussed with @qsellem we should also cover the spans attached via Request.Builder.tag(DatadogSpan::class.java, span) (and parentSpan(...) / Cronet's addRequestAnnotation(span)) in this PR

I've added 6ebf523.

When a customer attaches a DatadogSpan or TraceContext as a parent via Request.Builder.parentSpan(span) (OkHttp) or addRequestAnnotation(span) (Cronet), the resulting tag-attached parent is now subject to the same dropped-parent ignore logic as the local active span on the RUM-to-APM path. This aligns Android's behavior with iOS RUM-15310 for the case of an explicit developer-attached parent that gets sampled out by the Trace SDK.

The change spans two coordinated layers. First, extractParentContext returns a ParentContextSource sealed type (FromHeaders or FromTag) so buildSpan can discriminate the source and call ignoreActiveSpan() when a tag parent is dropped. Second, extractSamplingDecision now takes an ignoreLocalDroppedParent flag (threaded through DatadogSpan.sample(...) from canSendSpan) that returns null for dropped tag parents so the trace sampler decides instead of inheriting the drop. Inbound trace headers remain authoritative in both layers to preserve head-based sampling of upstream propagation.
@hamorillo
hamorillo requested a review from 0xnm May 20, 2026 14:00
0xnm
0xnm previously approved these changes May 21, 2026
Comment on lines +384 to +390
private fun DatadogSpanContext?.isDropped(): Boolean {
val priority = this?.samplingPriority
return priority.isDroppedPriority()
}

private fun Int?.isDroppedPriority(): Boolean =
this == PrioritySampling.SAMPLER_DROP || this == PrioritySampling.USER_DROP

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is duplication between here and similar code in ApmNetworkInstrumentation, but I guess it will be gone at some point with further changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was aware about it but yeah, let's remove it. I've refactor a bit the code in d7dd316

@datadog-prod-us1-6

This comment has been minimized.

@hamorillo
hamorillo merged commit 7bba71a into develop May 21, 2026
27 checks passed
@hamorillo
hamorillo deleted the hector.morilloprieto/RUM-15138-ignore-dropped-parent branch May 21, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants