Skip to content

feat(tracing): emit OTel HTTP semantic conventions behind DD_TRACE_OTEL_SEMANTICS_ENABLED - #19501

Draft
link04 wants to merge 39 commits into
mainfrom
otel-http-semantics
Draft

link04 wants to merge 39 commits into
mainfrom
otel-http-semantics

Conversation

@link04

@link04 link04 commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Important

This original PR is retained as historical review and system-test context. Active review continues in the native GitHub stack: foundation #20008, HTTP client #20009, and HTTP server/framework/AppSec #20010. Please place new feedback on the relevant layer to avoid duplicate work. All existing review threads, including resolved and outdated threads, were reconciled against the replacement stack.

What does this PR do?

Implements the agreed OpenTelemetry HTTP semantic conventions behind DD_TRACE_OTEL_SEMANTICS_ENABLED for Python HTTP server and client spans.

The flag now provides one coherent mode across capture, sampling, and export:

  • emits OTel HTTP attribute names & values and removes their DD ones
  • emits server.port and http.response.status_code as numeric OTLP attributes
  • derives low-cardinality HTTP span names from method plus route when available, using HTTP for unknown methods
  • makes a valid low-cardinality semantic resource available before sampling rules run
  • preserves resources identified as user-owned
  • retains http.endpoint for route-less responses
  • forces OTLP trace export, schema v0, and peer-service defaults off when semantics mode is enabled
  • redacts URL credentials without leaking passwords that contain @

HTTP tag changes

When OpenTelemetry semantics are enabled, Python emits the following tag names and omits the corresponding DD aliases. Tags not listed here, including http.route, http.endpoint, configured header tags, and retry/status-message tags, retain their existing names.

Concept Datadog semantics OpenTelemetry semantics Applies to
HTTP method http.method http.request.method Server and client
Original HTTP method (not emitted) http.request.method_original when normalization changes the value Server and client
Client request URL http.url url.full with credential redaction and query obfuscation Client
Server request URL http.url url.path, url.scheme, and optional url.query Server
Query string http.query.string or query inside http.url url.query Server
Target host out.host server.address Client
Server address derived from URL Part of http.url server.address Server
Server port (not emitted by this HTTP path) server.port Server and client
HTTP response status http.status_code as a string http.response.status_code as an integer over OTLP Server and client
User agent http.useragent user_agent.original Server and client
Resolved client address http.client_ip client.address Server
Socket peer address network.client.ip network.peer.address Server
Status-derived error type (not emitted for status-only errors) error.type containing the response status when it caused the error Server and client

No new HTTP status-code setting is introduced. Existing server status configuration remains covered. DD_TRACE_HTTP_CLIENT_ERROR_STATUSES is not implemented by this PR, and its two system tests remain explicit missing_feature expectations.

Dependency

This PR depends on DataDog/libdatadog#2323 being merged and released. Python currently consumes libdatadog v41, so the released compatible version must include the trace-metric and sampled-flag changes before this feature can be released. The dependency override used for local verification is not committed in this branch.

Motivation

Customers migrating from an OpenTelemetry SDK should retain OTel HTTP names, types, status behavior, low-cardinality span names, sampling inputs, and trace metrics. An export-only rename is insufficient because sampling and stats consume spans earlier.

Companion changes:

Testing

Focused Python verification: 24 passed.

System-tests using a wheel built from this branch with the source changes from libdatadog #2323 overlaid on the v41-compatible dependency graph:

Scenario Result
OTEL_SEMANTICS_OTLP 30 passed
OTEL_SEMANTICS_OTLP_CUSTOM_ERROR_STATUSES 2 passed, 2 expected missing features
OTEL_SEMANTICS_OTLP_TRACE_METRICS 4 passed
OTEL_SEMANTICS_OTLP_SAMPLING_RULES 2 passed

link04 and others added 4 commits August 3, 2026 23:02
…s config

Adds the OpenTelemetry HTTP semantic convention attribute names next to the
Datadog ones in ddtrace.ext.http and ddtrace.ext.net, and a client side error
status setting so HTTP client spans can have their own error ranges.

DD_TRACE_HTTP_CLIENT_ERROR_STATUSES (alias DD_HTTP_CLIENT_ERROR_STATUSES) is
unset by default, which keeps the current behavior of applying the server
ranges to client spans. DD_TRACE_HTTP_SERVER_ERROR_STATUSES now also accepts
the DD_HTTP_SERVER_ERROR_STATUSES spelling that the other tracers support.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the OpenTelemetry HTTP semantic conventions for client and server
spans behind DD_TRACE_OTEL_SEMANTICS_ENABLED. The whole implementation lives in
set_http_meta and the URL tagging helpers, so no integration needs to know
which mode is active.

set_http_meta is called on every HTTP span, so instead of branching on the flag
per call the OTel implementation is bound to the name once at import time. The
flag cannot change after startup, which makes the swap safe, and it keeps the
Datadog path free of any new work.

Values are deliberately produced by the same helpers as before, so enabling the
flag moves attribute names without changing what a value contains: credential
redaction and query obfuscation behave the same. The exceptions are the two
places the spec requires: url.full keeps redacted userinfo instead of dropping
it, and url.query is always obfuscated because it is the only place a server
query string can live once http.url is gone.

Server spans mark 5xx as errors, client spans mark 4xx and 5xx, and error.type
carries the status code as a string unless an exception already set it. A
user configured error status range still wins in either mode.

Numeric attributes are written as integers when the process exports over OTLP
and as strings otherwise, because DD MsgPack requires them in the meta map
while OTLP requires the integer type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything that reads HTTP tags back off a span now resolves the attribute name
from the active semantics mode: endpoint simplification, API Security's URL
fallback, AI Guard's client IP copies, peer.service inference, the inferred
proxy span, and the few places outside set_http_meta that tag a status code, a
URL, a query string or a method on a request span.

http.endpoint has no OTel equivalent, so it is retained in both modes and stays
the primary source for API Security and endpoint aggregation. The Datadog
spellings that intake keys off, such as the ai_guard.* copies and the inferred
proxy span's own tags, also stay as they are; only the attribute their value is
read from moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The implementation is swapped in at import time, so patching the config at
runtime has no effect and these tests set DD_TRACE_OTEL_SEMANTICS_ENABLED in a
subprocess instead. They build spans directly rather than through a tracer so
they do not need a trace agent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/_trace/otel_http_naming.py                                      @DataDog/apm-sdk-capabilities-python
ddtrace/_trace/processor/resource_renaming.py                           @DataDog/apm-sdk-capabilities-python
ddtrace/_trace/subscribers/http_client.py                               @DataDog/apm-sdk-capabilities-python @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/_trace/subscribers/web_framework.py                             @DataDog/apm-sdk-capabilities-python @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/_trace/trace_handlers.py                                        @DataDog/apm-sdk-capabilities-python @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/aiguard/_api_client.py                                          @DataDog/asm-python
ddtrace/appsec/_api_security/api_manager.py                             @DataDog/asm-python
ddtrace/appsec/_contrib/django/__init__.py                              @DataDog/asm-python
ddtrace/appsec/_contrib/fastapi/__init__.py                             @DataDog/asm-python
ddtrace/appsec/_contrib/flask/__init__.py                               @DataDog/asm-python
ddtrace/contrib/internal/asgi/middleware.py                             @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/cherrypy/patch.py                              @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/django/patch.py                                @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/django/response.py                             @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/django/utils.py                                @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/falcon/middleware.py                           @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/httplib/patch.py                               @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/sanic/patch.py                                 @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/starlette/patch.py                             @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/tornado/handlers.py                            @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/trace_utils.py                                 @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/trace_utils_base.py                            @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/contrib/internal/wsgi/wsgi.py                                   @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/ext/http.py                                                     @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/ext/net.py                                                      @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/internal/constants.py                                           @DataDog/apm-core-python
ddtrace/internal/schema/default.py                                      @DataDog/apm-core-python @DataDog/apm-idm-python
ddtrace/internal/settings/_agent.py                                     @DataDog/apm-core-python
ddtrace/internal/settings/_config.py                                    @DataDog/python-guild @DataDog/apm-sdk-capabilities-python
ddtrace/internal/settings/_opentelemetry.py                             @DataDog/apm-sdk-capabilities-python
ddtrace/internal/settings/peer_service.py                               @DataDog/apm-core-python
releasenotes/notes/otel-http-semantics-43cca3d1860400ba.yaml            @DataDog/apm-python
tests/appsec/contrib_appsec/test_django.py                              @DataDog/asm-python
tests/contrib/django/test_django.py                                     @DataDog/apm-core-python @DataDog/apm-idm-python
tests/contrib/django/test_django_request_path_params.py                 @DataDog/apm-core-python @DataDog/apm-idm-python
tests/contrib/django/test_django_snapshots.py                           @DataDog/apm-core-python @DataDog/apm-idm-python
tests/contrib/flask/test_request.py                                     @DataDog/apm-core-python @DataDog/apm-idm-python
tests/contrib/httpx2/test_httpx2.py                                     @DataDog/apm-core-python @DataDog/apm-idm-python
tests/contrib/starlette/test_starlette.py                               @DataDog/apm-core-python @DataDog/apm-idm-python
tests/internal/peer_service/test_processor.py                           @DataDog/apm-core-python
tests/snapshots/tests.contrib.django.test_django_snapshots.test_otel_semantics_request.json  @DataDog/apm-python
tests/snapshots/tests.contrib.django.test_django_snapshots.test_otel_semantics_request_unaccepted_method.json  @DataDog/apm-python
tests/tracer/test_agent.py                                              @DataDog/apm-sdk-capabilities-python
tests/tracer/test_otel_span_naming.py                                   @DataDog/apm-sdk-capabilities-python
tests/tracer/test_resource_renaming.py                                  @DataDog/apm-sdk-capabilities-python
tests/tracer/test_trace_utils.py                                        @DataDog/apm-sdk-capabilities-python
tests/tracer/test_writer.py                                             @DataDog/apm-sdk-capabilities-python

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@datadog-datadog-prod-us1

datadog-datadog-prod-us1 Bot commented Aug 4, 2026 •

Copy link
Copy Markdown
Contributor

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 215d466 | Docs | View more details | Give us feedback!

- gate the server.address peer-service source behind DD_TRACE_OTEL_SEMANTICS_ENABLED
- drop the un-gated DD_HTTP_SERVER_ERROR_STATUSES alias
- make _is_http_client_span span-kind aware
- mark the ray_serve inbound span as SERVER
- keep status and method on the span when get_request_uri throws
- share one path_source_tag_value helper for the endpoint source tag
- clarify the release note

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pr-commenter

pr-commenter Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-28 23:46:29

Comparing candidate commit 215d466 in PR branch otel-http-semantics with baseline commit 5ac329e in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 7 performance regressions! Performance is the same for 574 metrics, 10 unstable metrics, 2 known flaky benchmarks, 16 flaky benchmarks without significant changes.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

scenario:httppropagationextract-full_t_id_datadog_headers

  • 🟥 execution_time [+1.771µs; +1.919µs] or [+10.834%; +11.739%]

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+2.797µs; +2.948µs] or [+17.093%; +18.019%]

scenario:iastaspects-add_aspect

  • 🟥 execution_time [+7.969µs; +10.343µs] or [+7.879%; +10.227%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+138.265µs; +145.394µs] or [+34.761%; +36.554%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+39.587µs; +44.176µs] or [+27.834%; +31.061%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+609.431ns; +644.338ns] or [+22.498%; +23.787%]

scenario:tracer-small

  • 🟥 execution_time [+36.008µs; +39.111µs] or [+11.271%; +12.242%]

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-704.222ns; +760.374ns] or [-6.462%; +6.977%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-33.101ns; +33.351ns] or [-5.423%; +5.464%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1609.542ns; +1671.076ns] or [-9.469%; +9.831%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1568.796ns; +960.211ns] or [-11.787%; +7.214%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-327.218ns; +323.725ns] or [-8.903%; +8.808%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-257.456ns; +256.001ns] or [-8.767%; +8.717%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-64.988ns; +76.418ns] or [-5.573%; +6.553%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3688.134ns; +4299.179ns] or [-9.028%; +10.524%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-713.165ns; +826.746ns] or [-8.843%; +10.251%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-58967.697ns; +58576.354ns] or [-9.474%; +9.411%]

Known flaky benchmarks

These benchmarks are marked as flaky and will not trigger a failure. Modify FLAKY_BENCHMARKS_REGEX to control which benchmarks are marked as flaky.

scenario:iastaspects-title_noaspect

  • 🟥 execution_time [+23.722µs; +27.369µs] or [+12.189%; +14.063%]

scenario:span-start

  • 🟥 execution_time [+1.541ms; +1.789ms] or [+11.378%; +13.216%]

Known flaky benchmarks without significant changes:

  • scenario:errortrackingflasksqli-baseline
  • scenario:flasksimple-iast-get
  • scenario:iastaspects-casefold_aspect
  • scenario:iastaspects-casefold_noaspect
  • scenario:iastaspects-index_aspect
  • scenario:iastaspects-ljust_noaspect
  • scenario:iastaspects-lower_aspect
  • scenario:iastaspects-replace_aspect
  • scenario:iastaspects-swapcase_aspect
  • scenario:iastaspects-translate_aspect
  • scenario:iastaspects-translate_noaspect
  • scenario:iastaspects-upper_noaspect
  • scenario:packagespackageforrootmodulemapping-cache_off
  • scenario:packagespackageforrootmodulemapping-cache_on
  • scenario:sethttpmeta-all-enabled
  • scenario:telemetryaddmetric-record-100-metrics

link04 and others added 4 commits August 4, 2026 18:28
…e client setting

No tracer ships a DD_HTTP_CLIENT_ERROR_STATUSES name, so aliasing to it
invents a legacy spelling that never existed. Keep the single canonical
DD_TRACE_HTTP_CLIENT_ERROR_STATUSES, matching this repo's existing
DD_TRACE_HTTP_SERVER_ERROR_STATUSES, and add the missing docs entry.

Also flag the server.address peer.service source as pending sign-off.
New configuration in every tracer, so it belongs in its own cross-tracer PR
rather than riding along with the semconv attribute work. Under the flag the
client error range is now fixed at 400-599. The pre-existing
DD_TRACE_HTTP_SERVER_ERROR_STATUSES is untouched and still drives server spans.
DD_TRACE_HTTP_CLIENT_ERROR_STATUSES exists in Java, .NET, Go, Ruby and PHP.
Python is one of the two tracers that lack it, which is the actual reason the
range is fixed here.
…ntics

Each integration composes its resource name its own way, and none of those are
valid OTel span names. Django joins the method with the resolved route, falls
back to the view handler when there is none, and normalizes a Resolver404 to
"GET 404". Under DD_TRACE_OTEL_SEMANTICS_ENABLED the name is now recomputed from
the span's own attributes instead, by the OpenTelemetry Collector algorithm the
tracers agreed to adopt: http.request.method, plus the target if the span
published one, and the bare method otherwise. The target is http.route on server
spans and url.template on client spans.

Deriving rather than composing is the point. The target can never be something
the span does not also publish, which makes "instrumentation MUST NOT default to
using URI path as a target" unreachable rather than merely forbidden, and the
method token is whatever the attribute says, so a raw verb outside the accepted
set cannot leak into the name.

Two cases are deliberately left alone. A resource the application set itself
wins, for the same reason Span.update_name wins over instrumentation in the OTel
SDKs; Django already tracked that ownership and now records it. And an ASGI
websocket handshake reports "websocket" as its method, which normalizes to
_OTHER, so renaming those would collapse every websocket endpoint in an app onto
one name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
link04 and others added 2 commits August 13, 2026 22:42
Make configuration, resource naming, and sampling decisions consistent before OTLP export so downstream stats and rules observe the final semantic values.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Dependency direction analysis

📈 Existing violations got worse

29 pre-existing violation(s) increased in severity (e.g. their target became more depended-on, or got pulled into an import cycle), though the edge itself isn't new:

Show violations that got worse (showing 5 of 29 highest severity)
ddtrace.internal.opentelemetry.span -×-> ddtrace._trace.span  (product:opentelemetry -> product:tracing, score=56, +1 vs base)
ddtrace.internal.opentelemetry.thread_context -×-> ddtrace._trace.span  (product:opentelemetry -> product:tracing, score=56, +1 vs base)
ddtrace.llmobs._llmobs -×-> ddtrace._trace.span  (product:llmobs -> product:tracing, score=56, +1 vs base)
ddtrace.appsec._iast._iast_request_context -×-> ddtrace._trace.span  (product:appsec -> product:tracing, score=56, +1 vs base)
ddtrace.appsec._contrib.aws_lambda -×-> ddtrace._trace.span  (product:appsec -> product:tracing, score=56, +1 vs base)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

⚠️ Existing dependency direction violations

There are 240 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 240 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=135)
ddtrace.profiling.collector.pytorch -×-> ddtrace.trace  (product:profiling -> product:tracing, score=133)
ddtrace.llmobs._telemetry -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)
ddtrace.debugging._signal.model -×-> ddtrace.trace  (product:debugging -> product:tracing, score=133)
ddtrace.llmobs._integrations.base -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=133)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

Force OTLP export, preserve explicit resources, and align HTTP naming, status, and credential handling with the agreed semantic-convention scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
@link04
link04 marked this pull request as ready for review August 18, 2026 18:22
@link04
link04 requested review from a team as code owners August 18, 2026 18:22
@link04
link04 requested review from juanjux and mabdinur August 18, 2026 18:22

@dubloom dubloom 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.

I mostly let some high level comments because there will be second round of reviews.

Something that I think can become quickly tricky to understand is that, sometimes otel compatibility is hidden (for instance the name of a tag changes based on otel or not), sometimes it is explicit. I think we should try to make it implicit as most as possible

Comment thread ddtrace/contrib/internal/trace_utils_base.py Outdated
Comment on lines +216 to +226
def _normalize_http_method(method: str) -> tuple[str, Optional[str]]:
"""Split a raw HTTP method into (http.request.method, http.request.method_original).

The second element is None when it would duplicate the first, which is the only case
the semantic conventions allow http.request.method_original to be omitted. Case
normalization counts as a difference, so "get" yields ("GET", "get").
"""
upper = method.upper()
if upper in _KNOWN_HTTP_METHODS:
return upper, (None if upper == method else method)
return OTHER_HTTP_METHOD, method

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.

Can't we use an hashmap for that ? instead of calling upper ?
This is kind of a micro optimization especially as it is cached but I don't think we need a whole function call for that

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.

A finite raw-string map cannot recognize every mixed-case spelling without enumerating all case combinations or changing behavior.

I kept the cached upper() normalization because HTTP methods can arrive in arbitrary mixed case; a finite lookup would either enumerate every casing or change behavior.

Did you have a particular lookup structure in mind that preserves mixed-case normalization?

return OTHER_HTTP_METHOD, method


def _credentials_redacted_url(url: str) -> str:

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.

I think I saw munir making a comment like that but I think this kind of helper function should live in a opentelemetry module. That would make file smaller and makes code clearer

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.

This overlaps with Munir’s helper-placement thread. Span-dependent helpers cannot move without introducing dependency-direction violations. Would you prefer moving only the pure parsing and formatting helpers into ddtrace.internal.opentelemetry.utils?

Comment thread ddtrace/contrib/internal/trace_utils_base.py Outdated
Comment thread ddtrace/contrib/internal/cherrypy/patch.py Outdated
Comment thread ddtrace/contrib/internal/cherrypy/patch.py Outdated
Comment thread ddtrace/_trace/subscribers/http_client.py Outdated
link04 and others added 16 commits August 25, 2026 17:18
Resolve the semantics mode once instead of storing it as a module-level flag:
each dual-behaviour helper now has a Datadog and an OpenTelemetry
implementation, and one block binds the public names. Nothing branches per
span and no copy of the setting is kept for other modules to read.

Report the DD_TRACE_SPAN_ATTRIBUTE_SCHEMA conflict once from Config rather
than latching a module-level bool in the schema module, name the feature that
gets disabled in the peer-service warning, and drop the duplicate telemetry
entries for configurations _get_config already reports.

Rename RESOURCE_SET_BY_OTEL to INSTRUMENTATION_HTTP_RESOURCE: the value stored
is the resource the integration composed, which is not necessarily an OTel
name. It is a span context item and never serialized, so span output is
unchanged.

Trim comments that restated the environment variable name, and shorten the
release note to a summary plus a docs link.
The previous commit bound implementations at import. That removed the stored
module-level flag but kept the part the review actually objected to: the value
is still frozen at import, so mock.patch.object(config,
"_otel_trace_semantics_enabled", True) has no effect and only subprocess tests
can exercise the flag.

#17718 reads config._otel_trace_semantics_enabled at call time and its tests
patch it with mock.patch.object. Follow that: the helpers in trace_utils_base
read the setting directly, and SERVER_URL_TAG / USER_AGENT_TAG become
server_url_tag() / user_agent_tag() so the attribute name is resolved per call
too. The lookup is a plain instance attribute, about 5ns more than a module
global, which is not worth freezing behaviour for.

Callers updated: starlette, and the AppSec flask and fastapi contribs.
Applies the review's core point: the span should be created with its OTel
name rather than remapped later. set_http_meta is already the single writer
of http.request.method and http.route, so it now composes the resource there
via set_otel_http_resource. An integration that resolves its route on a
second call, starlette being the case that forced the old two-phase design,
simply calls again and the more specific name wins.

OtelSpanNamingProcessor is deleted, along with its registration and the
before_sampling hook in Tracer.sample. The guards it needed because it saw
every span (span type, missing method) are unnecessary from the integration:
spans that never call set_http_meta are never named. The naming algorithm,
the websocket exclusion and the user-override detection move to
ddtrace/_trace/otel_http_naming.py, which contrib may import (layers.json
allows contrib -> product:tracing).

Tests drive set_http_meta through tracer.start_span instead of constructing
Span objects and invoking a processor, which also addresses the review note
about building spans directly in this file.
Removing the before_sampling hook left a gap the review caught. Propagation
can force a sampling decision before the request finishes, and only the HTTP
client subscriber set a resource at span start, so a rule could match a
server span's Datadog resource (aiohttp.request) while the span exported as
"GET /users/{id}".

Both subscribers now name the span through set_otel_http_resource on start.
Server spans get the method alone, which is what before_sampling produced at
that point, and set_http_meta appends the route later. Routing the client
through the same helper also normalizes an unaccepted method at start, so
sampling no longer sees PROPFIND on a span that ships as HTTP.
An integration that writes span.resource mid-request is indistinguishable
from user code doing the same, so the OTel naming backed off and left the
Datadog value on the span. Falcon's process_resource is the clear case: it
set "GET SomeResource" and the span then exported with that instead of
"GET /things/{id}".

set_instrumentation_resource sets the resource and records instrumentation
as its owner in one step, so the marker cannot drift from the value. Applied
to the HTTP server integrations that write the resource outside the
subscriber: falcon (both sites), asgi's 404 path, and tornado's handler name.

Also records why the helpers stay in ddtrace/_trace: moving them under
ddtrace/internal/opentelemetry, as the review suggested, adds 7 dependency
direction violations, because that package is zoned as an isolated product
while this code operates on Span and is imported by both contrib and the
tracing subscribers.
…tests

The HTTP helpers this PR adds to trace_utils_base are imported by the
subscribers, the AppSec contribs and several integrations, so the leading
underscore was wrong: normalize_http_method, set_method_tag,
set_query_string_tag, set_status_code_tag, set_url_tags_server and
http_block_metadata are now public. Helpers that were already private on main
are left alone.

DEFAULT_SCHEME_PORTS moves to ddtrace/internal/constants.py, which contrib may
import, since it is a fixed scheme-to-port table rather than configuration.

The Django coverage no longer mocks the resolver and the config to call a
private helper. Three subprocess tests drive a real request instead and assert
what the span carries: the route-derived name with the flag on, HTTP
substituted for an unaccepted method, and the Datadog names unchanged with the
flag off.
…s its own

Several integrations create the span with a resource already set: requests
passes "GET /actual/path" through HttpClientRequestEvent. The subscriber then
asked set_otel_http_resource to name the span, which saw a resource matching
neither the span name nor any marker, concluded a user owned it, and left the
URI path as the span name for both sampling and export. That is the shape the
conventions explicitly forbid.

The subscribers now record instrumentation ownership of the event-supplied
resource before naming, so the rename proceeds while a user replacing the
resource later is still honoured.

The same unmarked-write audit is extended past falcon, asgi and tornado to
sanic, starlette and the WSGI span modifier, which all compose a resource
outside the subscriber. httplib's post-set_http_meta override is deleted
instead: set_http_meta now names the span, and the override reintroduced
PROPFIND over the HTTP the conventions require.
The requests regression got through because every naming test called
set_otel_http_resource directly with a default resource, so none of them
exercised a span that arrives with a resource already set by the integration.

These drive core.context_with_event with requests-shaped event data and assert
what a sampling rule would match: GET and HTTP rather than the URI path, for
an accepted method, an unaccepted one and a lowercase one. A second test pins
that a user replacing the resource after start still wins, so the ownership
marker cannot become a blanket override of user intent.

Verified by removing the ownership record and watching the first test fail.
Deleting the before_sampling hook rested on an untested assumption: that the
span carries its OTel name by the time a rule is evaluated. These two
subprocess tests settle it from opposite directions.

A rule on the OTel name rejects the span, and a rule on the resource the
integration supplied does not match at all, so the rename provably happens
before sampling rather than only before export.
_set_http_meta_otel was a 124-line near-copy of set_http_meta: 68 of its lines
were identical, including client-IP resolution, header tracing, response
headers, cookies and the AppSec dispatch. That duplication is what the review
objected to, so the two are now one function that branches only where the
conventions actually differ, which is eight places: the method attribute, URL
tagging, server.address precedence, the status code and its error semantics,
the query string, and the user-agent, client-IP and peer-IP names.

Removing the import-time binding also finishes the earlier change: the setting
is read per call here as well as in trace_utils_base, so
mock.patch.object(config, "_otel_trace_semantics_enabled", True) now switches
set_http_meta itself, matching how #17718's tests drive the flag. Previously
only the helpers responded to it, which was the inconsistency raised in review.

Verified by capturing the tags, metrics, resource and error flag for 14 request
shapes in both flag states before and after: output is identical apart from
per-process values. trace_utils.py loses 124 lines.
The review asked for snapshots as well as subprocesses. These were not
possible while the setting was frozen at import, because a snapshot test drives
the in-process client; collapsing set_http_meta made the flag readable per call,
so mock.patch.object now switches the whole request path.

Two snapshots over a real request: the route-derived name with the flag on, and
an unaccepted method reported as _OTHER while the span is named HTTP. They
capture the whole span rather than the handful of tags a unit test asserts, so
the absence of the Datadog names is covered too.
Track late integration resource updates so OTel naming and trace-level sampling remain consistent, and cover the affected framework and client lifecycles.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep user and processor resource customizations intact while ensuring sampling observes normalized routes without executing framework routing logic early.

Co-authored-by: Cursor <cursoragent@cursor.com>
Prevent unrelated deprecated trace ID settings inherited from CI from polluting subprocess stderr.

Co-authored-by: Cursor <cursoragent@cursor.com>
@link04 link04 added OpenTelemetry AI Generated Largely based on code generated by an AI or LLM. This label is the same across all dd-trace-* repos ai-generated PR created with AI assistance labels Aug 28, 2026
link04 and others added 3 commits August 28, 2026 02:46
Retain manually resolved matches for downstream AppSec consumers and close test SQLite connections across URLConf reloads.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve routes through Django's own lifecycle so AppSec can block path attacks without invoking converters twice.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use Django's pre-view middleware stage on versions without resolve_request so AppSec blocks early without running path converters twice.

Co-authored-by: Cursor <cursoragent@cursor.com>

@emmettbutler emmettbutler left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This change is quite large - consider breaking it up into smaller sections that can be merged independently. I flagged a few separation-of-concerns issues that should be adjusted before this is merged.

except (TypeError, ValueError):
log.debug("failed to convert http status code %r to int", status_code)
return
span._set_attribute(http.OTEL_RESPONSE_STATUS_CODE, otel_number(int_status_code))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code that sets attributes on Span objects should live under ddtrace/_trace, not ddtrace/contrib

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.

Thank you @emmettbutler, I've applied those in the new PRs, feel free to review:

  1. feat(tracing): add OTel HTTP semantics foundation #20008
  2. feat(tracing): emit OTel HTTP semantics for client spans #20009
  3. feat(tracing): emit OTel HTTP semantics for server spans #20010

Let me know if you would like the separated by Integration change instead.

if server_address is not None:
span._set_attribute(net.SERVER_ADDRESS, server_address)
elif target_host is not None:
span._set_attribute(net.SERVER_ADDRESS, target_host)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code that sets attributes on Span objects should live under ddtrace/_trace, not ddtrace/contrib

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.

Thank you @emmettbutler, I've applied those in the new PRs, feel free to review:

  1. feat(tracing): add OTel HTTP semantics foundation #20008
  2. feat(tracing): emit OTel HTTP semantics for client spans #20009
  3. feat(tracing): emit OTel HTTP semantics for server spans #20010

Let me know if you would like the separated by Integration change instead.

@link04
link04 marked this pull request as draft September 1, 2026 21:26
gh-worker-dd-mergequeue-cf854d Bot pushed a commit to DataDog/libdatadog that referenced this pull request Sep 2, 2026
…nsions (#2323)

### What does this PR do?

Preserves OpenTelemetry HTTP semantics in libdatadog's in-process consumers and OTLP output.

#### Trace stats and OTLP trace metrics

- reads canonical Datadog HTTP method/status keys first, then falls back to `http.request.method` and `http.response.status_code`
- accepts valid numeric or string status values without allowing empty, malformed, non-finite, or non-integral values to shadow a valid fallback
- emits OTel trace-metric dimensions for method, status, route, span name, and kind
- leaves successful `status.code` unset and prevents additional tags from overriding the computed status

### HTTP tag handling

libdatadog does not rename tracer span tags in this PR. It accepts the Datadog and OpenTelemetry names below as equivalent inputs to trace-stat aggregation and emits the canonical OpenTelemetry name in OTLP trace metrics.

| Concept | Datadog span input | OpenTelemetry span input | OTLP trace-metric output |
| --- | --- | --- | --- |
| HTTP method | `http.method` | `http.request.method` | `http.request.method` |
| HTTP response status | `http.status_code` | `http.response.status_code` | `http.response.status_code` as an integer |
| Span error status | Span error flag | Span error flag | `status.code=STATUS_CODE_ERROR` for errors; omitted for successful spans |

When both naming variants are present, the Datadog name wins. Empty or malformed values fall through to the alternate name instead of hiding a valid value.

#### OTLP sampled flags

Sampling priority is a trace-level decision, but v0.4/v0.5 inputs may attach `_sampling_priority_v1` to only one span. The mapper now uses the same first-priority policy as `drop_chunks`, treats a missing priority as sampled, and applies one decision to every span in the chunk.

This prevents sampled traces from exporting child spans with `flags=0` and keeps OTLP flags consistent with the existing chunk-retention policy.

No public signature, wire-format, setting, or environment-variable change is introduced.

### Motivation

Under `DD_TRACE_OTEL_SEMANTICS_ENABLED`, spans use OTel HTTP names before client-side stats are computed. Stats and OTLP metrics must preserve those dimensions, and all spans in an exported trace must carry the same sampled decision.

Companion changes:

- Historical full Python PR: [DataDog/dd-trace-py#19501](DataDog/dd-trace-py#19501)
- Native Python stack: [foundation #20008](DataDog/dd-trace-py#20008), [client #20009](DataDog/dd-trace-py#20009), [server #20010](DataDog/dd-trace-py#20010)
- [DataDog/dd-trace-js#9688](DataDog/dd-trace-js#9688)
- [DataDog/system-tests#7455](DataDog/system-tests#7455)

### Testing

- `cargo fmt --check -p libdd-trace-utils`
- mapper unit tests: **31 passed**
- OTLP trace-exporter integration tests: **3 passed**
- trace-stats tests: **43 passed**

End-to-end re-run from libdatadog head `0ed7fd9bd` with the final Python server layer and companion system tests:

| Scenario | Result |
| --- | ---: |
| `OTEL_SEMANTICS_OTLP_TRACE_METRICS` | **4 passed** |
| `OTEL_SEMANTICS_OTLP_SAMPLING_RULES` | **2 passed** |

Co-authored-by: maximo.bautista <maximo.bautista@datadoghq.com>
gh-worker-dd-mergequeue-cf854d Bot pushed a commit that referenced this pull request Oct 9, 2026
## Description

Adds the shared foundation for emitting OpenTelemetry HTTP semantic conventions when `DD_TRACE_OTEL_SEMANTICS_ENABLED=true`.

This PR does **not** change HTTP integrations yet. The client and server integrations start using this foundation in #20009 and #20010.

This is the first part of the implementation for #19501.

### Configuration

`DD_TRACE_OTEL_SEMANTICS_ENABLED` remains disabled by default. When enabled, it enforces the settings required by the OTel HTTP semantics path:

- OTLP trace export
- Span attribute schema `v0`
- Peer-service defaults disabled
- Native span events enabled for OTLP export

Conflicting settings are overridden and reported through logs and telemetry.

### Shared HTTP semantics

The implementation lives under `ddtrace/_trace/otel/http/`:

- `tags.py` handles HTTP method normalization, URL attributes, server address and port extraction, query-string handling, status codes, and client/server error classification.
- `resource.py` generates low-cardinality HTTP resource names and prevents later instrumentation updates from overwriting resources set by user code.

`OTelHTTPSpanAttributes` is shared by the upcoming client and server layers. Those layers will instantiate it from `set_http_meta()` and provide metadata as it becomes available during the request lifecycle.

For example:

```python
attributes = OTelHTTPSpanAttributes(span, integration_config)
attributes.set_method("get")
attributes.set_url("https://example.com/users/42")
attributes.set_status_code(200)
attributes.set_resource("/users/{id}")
```

This produces:

```text
http.request.method = "GET"
http.request.method_original = "get"
url.scheme = "https"
url.path = "/users/42"
http.response.status_code = 200
resource = "GET /users/{id}"
```

Recreating the helper during later metadata phases is supported: method, resource, and status-error ownership are stored on the span.

### OTel requirements covered

The behavior follows the [OpenTelemetry HTTP span conventions](https://opentelemetry.io/docs/specs/semconv/http/http-spans/).

| Scenario | OTel behavior | Covered behavior |
| --- | --- | --- |
| Method `get` | Normalize the method and preserve its original spelling | Emits `http.request.method="GET"` and `http.request.method_original="get"` |
| Unknown method `PROPFIND` | Emit `_OTHER` and retain the original method | Emits `_OTHER`, preserves `PROPFIND`, and uses `HTTP` for resource naming |
| Server route `/users/{id}` | Use the low-cardinality route template instead of the raw request path | Refines the resource to `GET /users/{id}` |
| Client URL containing credentials | Redact user information from `url.full` | Emits `https://REDACTED:REDACTED@example.com/search` |
| Client 4xx response | Mark the span as an error | Sets `span.error=1` and a string status-derived `error.type` |
| Server 4xx response | Do not mark the span as an error by default | Leaves `span.error=0` |
| Server 5xx response | Mark the span as an error | Sets `span.error=1` |

Existing Datadog query capture, query obfuscation, and custom server error-range settings remain supported. Exception information takes precedence over status-derived error metadata.

### Resource ownership

Resource naming distinguishes instrumentation-generated values from user replacements:

1. Instrumentation may progressively refine a resource as more metadata becomes available.
2. If user code replaces that resource, subsequent instrumentation updates preserve the user value.
3. Raw request paths are never used as OTel server resources; route templates are used when available.

WebSocket resources are left unchanged to avoid collapsing them to the unknown-method resource name.

## Testing

Unit coverage includes:

- Method normalization and stale `method_original` cleanup
- Client and server URL attributes
- Credential redaction and query-string obfuscation
- Malformed URLs
- Client/server status-code error classification
- Repeated metadata updates
- Exception preservation
- User-owned resource preservation
- Schema and peer-service overrides
- OTLP writer selection and native span events

End-to-end OTLP snapshots are added later in the stack because they depend on the harness introduced in #20688:

- #20689: HTTP client snapshots
- #20690: HTTP server and AppSec snapshots

## Risks

The feature remains opt-in. This PR configures the enabled mode and adds the shared implementation, but it does not change emitted HTTP integration spans until the client and server follow-up PRs are merged.

## PR stack

1. #20008 — shared configuration and HTTP semantics foundation
2. #20688 — OTLP snapshot harness
3. #20009 — HTTP client integration
4. #20689 — client configuration and snapshots
5. #20010 — HTTP server/framework and AppSec integration
6. #20690 — AppSec block-path attributes and server snapshots

Co-authored-by: Hanane-Laassilia <hanane.laassilia@datadoghq.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI Generated Largely based on code generated by an AI or LLM. This label is the same across all dd-trace-* repos ai-generated PR created with AI assistance OpenTelemetry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants