Skip to content

Commit 2d5e7cb

Browse files
fix(tracing): accept unknown sampling mechanisms in _dd.p.dm (#19337)
## Description **Real-world repro:** Datadog Synthetics emits sampling mechanism `15`, which isn't in dd-trace-py's local `SamplingMechanism` enum. Before this fix, that alone was enough to drop `_dd.p.dm`, mark the trace `_dd.propagation_error: decoding_error`, and log a warning — for a perfectly well-formed value. Fixes #19335. `validate_sampling_decision()` validated the incoming `_dd.p.dm` propagation tag against an allowlist derived from the **local** `SamplingMechanism` enum, so any mechanism id the installed version didn't know about was treated as corrupt: the tag was deleted, `_dd.propagation_error: decoding_error` was written to the context (and thus onto the chunk-root span), and a warning was logged. Because the inbound sampling priority is already set, `TraceSamplingProcessor` skips the sampler, so no replacement `_dd.p.dm` is generated — the trace segment permanently loses its ingestion-reason attribution, and nothing is propagated downstream. This replaces the membership check with a **syntax and range** check: accept `-N` where `N` is an ASCII decimal integer in `0..255`, matching libdatadog's `u8` encoding. Genuinely malformed values (`-`, `--1`, `-1.0`, `-1a`, out-of-range, the legacy service-hash form `934086a6-4`) keep the existing `decoding_error` behaviour. Why loosen it rather than add the missing id to the enum: - **The propagation spec treats the mechanism as an opaque integer.** dd-trace-cpp documents this as an explicit design requirement in [`include/datadog/sampling_mechanism.h`](https://github.com/DataDog/dd-trace-cpp/blob/main/include/datadog/sampling_mechanism.h): tracers that only decode locally-enumerated values make adding new values infeasible, so the mechanism "is treated as just an integer when being deserialized or serialized". libdatadog's `SamplingMechanism::from_str` accepts any value in `0..=255`. - **dd-trace-py is the only tracer that validates the mechanism *value*.** Java validates syntax only (`PTagsCodec.validateDecisionMakerTag`); Node, Go, .NET and Ruby apply generic tagset/charset decoding; PHP and C++ don't inspect the tag on extract. Full per-tracer breakdown in #19335. - **The current check couples the library to a registry it doesn't own.** Any component adopting a newly registered mechanism silently breaks decision-maker propagation for every dd-trace-py service downstream until those services upgrade. This is the third occurrence on this line: #3797's single-digit regex broke once the enum passed 9, #13554 replaced it with the enum-derived allowlist (#13516, `-11`), and #19335 is the same failure for `-15`. - **dd-trace-py is already inconsistent about it.** The W3C `tracestate` extract path applies no validation to `t.dm`, and `Span._set_sampling_decision_maker()` writes `"-%d" % mechanism` for any integer, so the same value is accepted or rejected depending on which header carried it. Implementation notes: - The valid set is a precomputed `frozenset` of 256 short strings, so the hot-path cost is identical to today's `in` check — no regex, no allocation, no `int()` parse. This also avoids `str.isdigit()`, which is `True` for non-ASCII digits. - An `AIDEV-NOTE:` anchor above the constant records why the check is deliberately loose, so this doesn't get "tightened" back into an enum allowlist a fourth time. - `SAMPLING_MECHANISM_CONSTANTS` is now unused but **deliberately kept**. Adding the rejected id to it (`SAMPLING_MECHANISM_CONSTANTS.add("-15")`) is the workaround users are applying on released versions, so removing the name would raise `AttributeError` at their startup on upgrade. Mutating it is now a harmless no-op. Happy to drop it if you'd rather not carry it — `ddtrace/internal` has no compatibility guarantee, so it's your call. ## Testing - `tests/tracer/test_propagation.py::test_extract_dm` — trimmed to 6 non-overlapping cases: `-0` and `-255` (boundaries), `-15` (unenumerated mid-range id, the real-world Synthetics/#19335 repro), and one case each for the distinct decoding-error modes (malformed syntax `-1a`, out-of-range `-256`, legacy service-hash form `934086a6-4`). The previous 12-case list had 7 malformed-input variants that all asserted the same `decoding_error` outcome; this keeps the meaningfully distinct failure modes without the redundant coverage. 6/6 pass. - `tests/integration/test_sampling.py` left as-is (already a reasonable, non-overlapping list): `test_malformed_sampling_mechanism` (parametrized over six malformed forms) and `test_supported_sampling_mechanism` (regression guard — every enum value is `<= 255`). - Run via `scripts/run-tests` in the `tracer` and `integration_testagent` venvs. Lint clean (`fmt`, `style`, `typing`). ## Risks Low, and one-directional: the change only **widens** what is accepted, so no value that propagated before stops propagating. - Behaviour change: well-formed mechanism ids outside the local enum are now forwarded instead of being dropped, and no longer set `_dd.propagation_error: decoding_error`. That is the fix. - The trade-off is that a well-formed but meaningless id in `0..255` supplied by an untrusted client now propagates rather than being stripped. This matches every other tracer, and the value is already opaque metadata — but flagging it explicitly since it's the one thing this loosens. - No public API change. No configuration change. Sampling and retention are unaffected (the priority travels in its own header). - This also removes the spurious `log.warning("failed to decode _dd.p.dm: %r", value)` that used to fire for every valid-but-unenumerated mechanism id, not just genuinely malformed ones. In production that warning was noisy and misleading — it fired continuously for the Synthetics `-15` case above even though nothing was actually corrupt. ## Additional Notes - Reported in #19335, which includes the production repro, the per-tracer comparison table, and the git archaeology. Prior rounds on this same line: #3797, #13516, #13554. - **Backport:** on 3.19.x this also rejects `-13` (AI Guard), which *is* a registered mechanism, so the case for `backport 3.19` looks stronger than for 4.x. I can't apply labels — please add them if you agree, or tell me and I'll open manual backport PRs for `3.19` / `4.13`. - **Out of scope:** the W3C `tracestate` extract path still applies no validation to `t.dm`. The asymmetry is pre-existing and adding validation there would *increase* strictness, so I've left it alone. Worth noting if the extractors are ever unified. - **Separate from this PR:** mechanism `15` appears to be emitted by Datadog Synthetics but isn't in the shared cross-tracer registry, libdatadog, or system-tests `dd_constants.py`. Probably worth registering upstream regardless of this fix. Co-authored-by: mabdinur <munir.abdinur@datadoghq.com>
1 parent 0ed515e commit 2d5e7cb

4 files changed

Lines changed: 27 additions & 12 deletions

File tree

ddtrace/internal/sampling.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ class PriorityCategory(object):
4242
RULE_DYNAMIC = "rule_dynamic"
4343

4444

45+
# AIDEV-NOTE: sampling mechanism is an opaque integer; validate syntax/range only, not enum
46+
# membership, or unenumerated-but-valid ids get silently dropped (#13516, #19335). Do not re-tighten.
47+
_MAX_SAMPLING_MECHANISM = 255 # libdatadog encodes the sampling mechanism as a u8
48+
VALID_SAMPLING_DECISIONS = frozenset("-%d" % value for value in range(_MAX_SAMPLING_MECHANISM + 1))
49+
50+
# Unused, kept so external `.add()` calls (a past workaround) don't AttributeError on upgrade.
4551
SAMPLING_MECHANISM_CONSTANTS = {
4652
"-{}".format(value) for name, value in vars(SamplingMechanism).items() if name.isupper()
4753
}
@@ -77,7 +83,7 @@ def validate_sampling_decision(
7783
value = meta.get(SAMPLING_DECISION_TRACE_TAG_KEY)
7884
if value:
7985
# Skip propagating invalid sampling mechanism trace tag
80-
if value not in SAMPLING_MECHANISM_CONSTANTS:
86+
if value not in VALID_SAMPLING_DECISIONS:
8187
del meta[SAMPLING_DECISION_TRACE_TAG_KEY]
8288
meta["_dd.propagation_error"] = "decoding_error"
8389
log.warning("failed to decode _dd.p.dm: %r", value)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
tracing: Fixes an issue where the sampling decision of an incoming distributed trace was
5+
discarded, reported as a propagation error on the local root span, and logged as a warning
6+
when the trace was sampled by a mechanism that is not known to this version of the library.
7+
Well-formed sampling decisions are now propagated unchanged.

tests/integration/test_sampling.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,14 +57,15 @@ def test_supported_sampling_mechanism():
5757
assert sampling_decision_validation != decoding_error_result, f"{mechanism} returned {decoding_error_result}"
5858

5959

60-
def test_unsupported_sampling_mechanism():
60+
@pytest.mark.parametrize("dm_value", ["-999999999999", "-256", "-", "--1", "-1.0", "934086a6-4"])
61+
def test_malformed_sampling_mechanism(dm_value):
6162
"""
62-
Unsupported sampling mechanisms actually return a decoding error in validate_sampling_decision
63+
Malformed sampling mechanisms actually return a decoding error in validate_sampling_decision
6364
"""
6465
from ddtrace.internal.constants import SAMPLING_DECISION_TRACE_TAG_KEY
6566
from ddtrace.internal.sampling import validate_sampling_decision
6667

67-
meta = {SAMPLING_DECISION_TRACE_TAG_KEY: "-999999999999"}
68+
meta = {SAMPLING_DECISION_TRACE_TAG_KEY: dm_value}
6869
sampling_decision_validation = validate_sampling_decision(meta)
6970
decoding_error_result = {"_dd.propagation_error": "decoding_error"}
7071
assert sampling_decision_validation == decoding_error_result, (

tests/tracer/test_propagation.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -935,14 +935,15 @@ def test_extract_unicode(tracer): # noqa: F811
935935
"x_datadog_tags, expected_trace_tags",
936936
[
937937
("_dd.p.dm=-0", {"_dd.p.dm": "-0"}),
938-
("_dd.p.dm=-0", {"_dd.p.dm": "-0"}),
939-
("_dd.p.dm=-", {"_dd.propagation_error": "decoding_error"}),
940-
("_dd.p.dm=--1", {"_dd.propagation_error": "decoding_error"}),
941-
("_dd.p.dm=-1.0", {"_dd.propagation_error": "decoding_error"}),
942-
(
943-
"_dd.p.dm=-22",
944-
{"_dd.propagation_error": "decoding_error"},
945-
), # This test validates a value that does not exist in the SamplingMechanism enum
938+
# Unenumerated but well-formed id: must still propagate. "-15" is the #19335 repro.
939+
("_dd.p.dm=-15", {"_dd.p.dm": "-15"}),
940+
("_dd.p.dm=-255", {"_dd.p.dm": "-255"}),
941+
# Malformed syntax.
942+
("_dd.p.dm=-1a", {"_dd.propagation_error": "decoding_error"}),
943+
# Out of the 0..255 range the mechanism is encoded in.
944+
("_dd.p.dm=-256", {"_dd.propagation_error": "decoding_error"}),
945+
# Legacy service hash form, dropped from the spec and never emitted by dd-trace-py.
946+
("_dd.p.dm=934086a6-4", {"_dd.propagation_error": "decoding_error"}),
946947
],
947948
)
948949
def test_extract_dm(x_datadog_tags, expected_trace_tags):

0 commit comments

Comments
 (0)