Skip to content

Parse Datadog trace and span id headers without exceptions - #12739

Open
dougqh wants to merge 11 commits into
masterfrom
dougqh/nonthrowing-trace-id-parse
Open

dougqh wants to merge 11 commits into
masterfrom
dougqh/nonthrowing-trace-id-parse

Conversation

@dougqh

@dougqh dougqh commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What Does This Do

Parses x-datadog-trace-id and x-datadog-parent-id headers without throwing.

  • LongStringUtils.parseUnsignedLongOrSentinel(CharSequence, start, len, ifInvalid): a new single-pass parser that never throws. It accepts 1 to 20 ASCII digits and returns a sentinel the caller chooses. An unsigned 64 bit id uses every bit (values above Long.MAX_VALUE are stored as negative longs), so the library cannot reserve a value for "invalid". The name says so, unlike the throwing parseUnsignedLong(String) and parseUnsignedLongHex(CharSequence, int, int, boolean).
  • LongStringUtils.isUnsignedLongZero(CharSequence, start, len): tells a real zero (1 to 20 '0' characters) apart from the 0 sentinel. Callers check it only when the parse returned 0.
  • LongStringUtils.parseUnsignedLong(String) now uses the new parser. A zero result falls back to the previous strict parser, so the input it accepts and the exceptions it throws are unchanged. Valid input gets faster (see below).
  • DD64bTraceId.fromOrNull(String): internal, returns null instead of throwing. It keeps the parsed string, so toString() on inject still doesn't re-format the id. Calling DDTraceId.from(long) would have lost that.
  • DatadogHttpCodec: malformed trace and span ids still invalidate the context and stop extraction, exactly as today, but there is no throw/catch, and the debug log is marked EXCLUDE_TELEMETRY. Bad input from a caller is not a tracer bug, and it repeats on every request from that caller.

Why 0 as the sentinel. 0 already means "no id" (ContextInterpreter treats a ZERO trace id as no context), so a caller that forgets the zero check still fails safe: a malformed header starts a fresh trace. A rarely seen valid value such as -1 would avoid most collisions, but a missed check would put every request with a bad header into one fake trace with id MAX. A Maybe / Try return would depend on escape analysis to avoid allocating on every request (and on boxing for a primitive payload), which is too risky on this path.

Motivation

Exceptions from parsing propagation headers are the largest group in Error Tracking for dd-trace-java 1.66 (the #apm-java-error-bots monitor scope). Over 30 days, issues 7d56e638 (numberFormatOutOfLongRange, 7.9M), 7d83ac0e (parseUnsignedLong, 3.6M), 4bd96434 (NumberFormatException.forInputString, 2.9M) and 7d4ce1b0 (DatadogHttpCodec.accept, 1.3M) all come through this path. They are marked IGNORED, which only stops the alerts: each one is still a thrown exception, with a full stack trace, on the request thread.

Additional Notes

Behavior change

On the header path only, a leading + and non-ASCII digits are now rejected. Before, ids of 18 digits or fewer went through Long.parseLong, which accepts both, while the hand-written 19 to 20 digit path rejected +. That mismatch suggests + support was never intended. The public DDTraceId.from / DDSpanId.from still accept both through the strict fallback.

Benchmarks

DatadogIdParseBenchmark (new; results also in its Javadoc). JDK 25, Apple M1 Max, 5 forks × 5 × 2s measurement. "Before" is origin/master running the same benchmark, without the fromOrNull arm. Each cell is the median of the 5 per-fork means, with the per-fork min-max in brackets, in ns/op (lower is better). I'm using medians and ranges because master's valid-id extraction is bimodal (one fork at 598 ns), and a mean with ± error hides that.

Full Datadog extraction (extract: trace id, parent id, sampling priority, user-agent):

x-datadog-trace-id before (ns) after (ns) change
invalid, not decimal 882 [760-884] 21 [21-21] ~42x faster
invalid, unsigned max + 1 840 [821-947] 43 [42-43] ~20x faster
valid, 18 digits 153 [152-153] 107 [100-109] -46 ns
valid, 19 digits 149 [119-598] 109 [108-110] -40 ns (median)

Parsing the id alone:

x-datadog-trace-id from + catch, before (ns) from + catch, after (ns) fromOrNull, after (ns)
invalid, not decimal 815 [804-836] 804 [792-809] 3 [3-3]
invalid, unsigned max + 1 875 [771-953] 919 [915-926] 24 [23-24]
valid, 18 digits 31 [31-31] 22 [22-23] 22 [22-23]
valid, 19 digits 32 [32-33] 24 [23-24] 24 [24-24]
  • Invalid headers drop from 840-880 ns to 21-43 ns per extraction, because no exception is built. The benchmark's call stack is shallow; in a real server the stack trace runs through the whole server and decorator chain, so the saving in production is larger.
  • Overflow vs not-decimal: overflow (24 ns to parse) is slower than not-decimal (3 ns) because all 20 digits are read before the overflow shows. Still about 36x cheaper than throwing.
  • Valid ids parse about 9 ns faster, because a plain ASCII loop replaces Long.parseLong and Character.digit. The throwing from gets the same gain, since it now uses the new parser first.
  • Extraction stability: every fork of this PR lands within a few ns. Master's valid-id extraction varies between forks and between runs (the 18-digit case was 119 ns in an earlier 2-fork run, 152 ns here). I haven't looked into why, so treat the valid-path extraction gain as roughly 40 ns at the median rather than a precise figure.
  • Throwing path unchanged: from + catch on invalid input costs about the same before and after (800-920 ns). That's expected; the public throwing API still builds the exception.
  • Logging: the test logback.xml on the JMH classpath sets the root logger to DEBUG. The benchmark resets it to INFO in @Setup. Otherwise the invalid arms measure console logging (about 240 µs/op on master).

Contributor Checklist

🤖 Generated with Claude Code

dougqh and others added 7 commits October 2, 2026 14:53
parseUnsignedLong(CharSequence, start, len, ifInvalid) accepts 1-20 ASCII
digits and returns a caller-chosen sentinel instead of throwing.
parseUnsignedLong(String) now uses it, falling back to the previous strict
parser only for a zero result, so its accepted input and exceptions are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Returns null instead of throwing, and keeps the parsed string so toString()
on inject does not re-format the id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Malformed x-datadog-trace-id / x-datadog-parent-id values still invalidate
the context and stop extraction, but no longer throw and catch a
NumberFormatException per request, and the debug log is excluded from
telemetry. A leading '+' and non-ASCII digits are now rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh dougqh added type: bug fix Bug fix comp: core Tracer core tag: performance Performance related changes tag: ai generated Largely based on code generated by an AI or LLM labels Oct 2, 2026
@datadog-datadog-prod-us1

This comment has been minimized.

@dd-octo-sts

dd-octo-sts Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

🟢 Java Benchmark SLOs — All performance SLOs passed

Suite Status
Startup 🟢 pass

SLO thresholds are defined here based on automatically generated metrics. A warning is raised when results are within 5% of the threshold.

PR vs. master results
Scenario Candidate master Δ (95% CI of mean)
startup:insecure-bank:iast:Agent 14.00 s 13.90 s [+0.1%; +1.3%] (maybe worse)
startup:insecure-bank:tracing:Agent 12.97 s 12.99 s [-0.9%; +0.6%] (no difference)
startup:petclinic:appsec:Agent 16.65 s 17.05 s [-6.6%; +1.9%] (no difference)
startup:petclinic:iast:Agent 16.99 s 16.97 s [-0.8%; +1.0%] (no difference)
startup:petclinic:profiling:Agent 16.06 s 16.65 s [-7.9%; +0.8%] (no difference)
startup:petclinic:sca:Agent 17.12 s 16.85 s [+0.5%; +2.6%] (maybe worse)
startup:petclinic:tracing:Agent 16.19 s 16.15 s [-0.6%; +1.1%] (no difference)

Commit: 232328c9 · CI Pipeline · Benchmarking Platform UI


Load and DaCapo benchmarks can be triggered manually in the GitLab pipeline. Results will appear in the Benchmarking Platform UI after completion.

dougqh and others added 2 commits October 5, 2026 11:06
fromOrNull and the span id case in DatadogHttpCodec both re-parsed with a
second sentinel to tell a real zero from invalid input. A single helper that
checks for 1-20 '0' characters replaces both, and is cheaper than a re-parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread dd-trace-api/src/main/java/datadog/trace/api/internal/util/LongStringUtils.java Outdated
dougqh and others added 2 commits October 5, 2026 11:41
Sharing a name with the throwing parseUnsignedLong(String) hid the opposite
failure contract at call sites, next to a throwing 4-arg parseUnsignedLongHex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…5 forks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dougqh
dougqh marked this pull request as ready for review October 6, 2026 13:46
@dougqh
dougqh requested review from a team as code owners October 6, 2026 13:46
@dougqh
dougqh requested review from mhlidd and removed request for a team October 6, 2026 13:46
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T13:58:13.797055Z 232328c Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 232328c94b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +191 to +193
long parsedSpanId =
parseUnsignedLongOrSentinel(spanIdValue, 0, len, DDSpanId.ZERO);
if (parsedSpanId == DDSpanId.ZERO && !isUnsignedLongZero(spanIdValue, 0, len)) {

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 Avoid the compound extractor's remaining span-id exception

When two or more propagation styles are enabled—which is the default—CompoundExtractor first populates ExtractionCache, whose cacheDatadogSpanId still calls the throwing DDSpanId.from(value) before this interpreter runs. Therefore every malformed x-datadog-parent-id still constructs and catches a NumberFormatException on the request path under the normal configuration, preserving the cost this change is intended to remove; update that cache path to use the non-throwing parser too.

Useful? React with 👍 / 👎.

@datadog-datadog-prod-us1 datadog-datadog-prod-us1 Bot left a comment

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.

Bits Code Review: FAIL

Overflowing start indices bypass the new helpers’ bounds checks, making an invalid slice appear to contain zero. Propagation callers use start=0, so this is an edge-case utility defect.

Open Bits AI session

🤖 Bits Code Review · Commit 232328c · @DataDog review to ask questions

*/
public static long parseUnsignedLongOrSentinel(
CharSequence s, int start, int len, long ifInvalid) {
if (s == null || len <= 0 || len > 20 || start < 0 || start + len > s.length()) {

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.

P2 Prevent index overflow in both range helpers

For input "1", start=Integer.MAX_VALUE, and len=1, start + len wraps negative and bypasses the bounds check. The parser returns 0 instead of the chosen sentinel, while isUnsignedLongZero incorrectly returns true without reading a character. Both helpers need overflow-safe bounds checks; the parser's start + 18 calculation also needs overflow-safe arithmetic.

Was this helpful? React 👍 or 👎
🤖 Bits Code Review · @DataDog review to ask questions · Open Bits AI session

@mhlidd mhlidd left a comment •

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.

Overall LGTM. Note that there is a call in HttpCodec.java that can still throw an exception. Not sure if that belongs in this PR or a follow-up.

edit: also mentioned in this AI comment.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp: core Tracer core tag: ai generated Largely based on code generated by an AI or LLM tag: performance Performance related changes type: bug fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants