Skip to content

Commit af2b9c0

Browse files
fix(otel): avoid tracing otlp connections [backport 3.17] (#15082)
Backport c426d0f from #14984 to 3.17. ## Description Prevents OpenTelemetry OTLP exporters from being traced by ddtrace to avoid circular instrumentation. ## Problem When OpenTelemetry metrics/logs exporters send data, ddtrace was instrumenting these internal connections, creating unwanted spans and potential circular tracing issues. ## Solution - Skip tracing gRPC channels with OpenTelemetry OTLP exporter user agents - Skip tracing HTTP requests from OpenTelemetry OTLP exporters - Added detection logic for both gRPC metadata and HTTP headers ## Testing Added tests verifying that OpenTelemetry exporters don't generate spans when `DD_LOGS_OTEL_ENABLED` is enabled. ## Risks Low - only affects OpenTelemetry integration when enabled. Co-authored-by: Munir Abdinur <[email protected]>
1 parent 25a7a37 commit af2b9c0

File tree

9 files changed

+153
-4
lines changed

9 files changed

+153
-4
lines changed

ddtrace/contrib/internal/grpc/client_interceptor.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from ddtrace.contrib import trace_utils
1414
from ddtrace.contrib.internal.grpc import constants
1515
from ddtrace.contrib.internal.grpc import utils
16+
from ddtrace.contrib.internal.grpc.utils import is_otlp_export
1617
from ddtrace.ext import SpanKind
1718
from ddtrace.ext import SpanTypes
1819
from ddtrace.internal import core
@@ -193,6 +194,13 @@ def __init__(self, pin, host, port):
193194
self._port = port
194195

195196
def _intercept_client_call(self, method_kind, client_call_details):
197+
metadata = []
198+
if client_call_details.metadata is not None:
199+
metadata = list(client_call_details.metadata)
200+
201+
if is_otlp_export(metadata):
202+
return None, client_call_details
203+
196204
tracer: Tracer = self._pin.tracer
197205

198206
# Instead of using .trace, create the span and activate it at points where we call the continuations
@@ -228,9 +236,6 @@ def _intercept_client_call(self, method_kind, client_call_details):
228236
# NOTE: We need to pass the span to the HTTPPropagator since it isn't active at this point
229237
HTTPPropagator.inject(span.context, headers, span)
230238

231-
metadata = []
232-
if client_call_details.metadata is not None:
233-
metadata = list(client_call_details.metadata)
234239
metadata.extend(headers.items())
235240

236241
client_call_details = _ClientCallDetails(
@@ -247,6 +252,8 @@ def intercept_unary_unary(self, continuation, client_call_details, request):
247252
constants.GRPC_METHOD_KIND_UNARY,
248253
client_call_details,
249254
)
255+
if span is None:
256+
return continuation(client_call_details, request)
250257
with _activated_span(self._pin.tracer, span):
251258
try:
252259
response = continuation(client_call_details, request)
@@ -265,6 +272,8 @@ def intercept_unary_stream(self, continuation, client_call_details, request):
265272
constants.GRPC_METHOD_KIND_SERVER_STREAMING,
266273
client_call_details,
267274
)
275+
if span is None:
276+
return continuation(client_call_details, request)
268277
with _activated_span(self._pin.tracer, span):
269278
response_iterator = continuation(client_call_details, request)
270279
response_iterator = _WrappedResponseCallFuture(response_iterator, span, self._pin.tracer)
@@ -275,6 +284,8 @@ def intercept_stream_unary(self, continuation, client_call_details, request_iter
275284
constants.GRPC_METHOD_KIND_CLIENT_STREAMING,
276285
client_call_details,
277286
)
287+
if span is None:
288+
return continuation(client_call_details, request_iterator)
278289
with _activated_span(self._pin.tracer, span):
279290
try:
280291
response = continuation(client_call_details, request_iterator)
@@ -293,6 +304,8 @@ def intercept_stream_stream(self, continuation, client_call_details, request_ite
293304
constants.GRPC_METHOD_KIND_BIDI_STREAMING,
294305
client_call_details,
295306
)
307+
if span is None:
308+
return continuation(client_call_details, request_iterator)
296309
with _activated_span(self._pin.tracer, span):
297310
response_iterator = continuation(client_call_details, request_iterator)
298311
response_iterator = _WrappedResponseCallFuture(response_iterator, span, self._pin.tracer)

ddtrace/contrib/internal/grpc/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@
2424
GRPC_AIO_SERVICE_SERVER = "grpc-aio-server"
2525
GRPC_SERVICE_CLIENT = "grpc-client"
2626
GRPC_AIO_SERVICE_CLIENT = "grpc-aio-client"
27+
USER_AGENT_HEADER = "user-agent"

ddtrace/contrib/internal/grpc/utils.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import ipaddress
22
import logging
33
import re
4+
from typing import Tuple
45
from urllib import parse
56

7+
from ddtrace import config
68
from ddtrace.contrib.internal.grpc import constants
9+
from ddtrace.contrib.internal.grpc.constants import USER_AGENT_HEADER
710
from ddtrace.ext import net
11+
from ddtrace.internal.opentelemetry.constants import OTLP_EXPORTER_HEADER_IDENTIFIER
812

913

1014
log = logging.getLogger(__name__)
@@ -108,3 +112,18 @@ def _parse_rpc_repr_string(rpc_string, module):
108112

109113
# Return the status code and details
110114
return code, details
115+
116+
117+
def is_otlp_export(metadata: Tuple) -> bool:
118+
"""
119+
Determine if a gRPC channel is submitting data to the OpenTelemetry OTLP exporter.
120+
"""
121+
if not (config._otel_logs_enabled or config._otel_metrics_enabled):
122+
return False
123+
124+
for key, value in metadata:
125+
if key == USER_AGENT_HEADER:
126+
normalized_value = value.lower().replace(" ", "-")
127+
if OTLP_EXPORTER_HEADER_IDENTIFIER in normalized_value:
128+
return True
129+
return False

ddtrace/contrib/internal/requests/connection.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33
from typing import Optional # noqa:F401
44
from urllib import parse
55

6+
import requests
7+
68
import ddtrace
79
from ddtrace import config
810
from ddtrace._trace.pin import Pin
911
from ddtrace.constants import _SPAN_MEASURED_KEY
1012
from ddtrace.constants import SPAN_KIND
1113
from ddtrace.contrib import trace_utils
14+
from ddtrace.contrib.internal.requests.constants import USER_AGENT_HEADER
1215
from ddtrace.contrib.internal.trace_utils import _sanitized_url
1316
from ddtrace.ext import SpanKind
1417
from ddtrace.ext import SpanTypes
1518
from ddtrace.internal.constants import COMPONENT
1619
from ddtrace.internal.logger import get_logger
20+
from ddtrace.internal.opentelemetry.constants import OTLP_EXPORTER_HEADER_IDENTIFIER
1721
from ddtrace.internal.schema import schematize_url_operation
1822
from ddtrace.internal.schema.span_attribute_schema import SpanDirection
1923
from ddtrace.internal.utils import get_argument_value
@@ -24,6 +28,17 @@
2428
log = get_logger(__name__)
2529

2630

31+
def is_otlp_export(request: requests.models.Request) -> bool:
32+
"""Determine if a request is submitting data to the OpenTelemetry OTLP exporter."""
33+
if not (config._otel_logs_enabled or config._otel_metrics_enabled):
34+
return False
35+
user_agent = request.headers.get(USER_AGENT_HEADER, "")
36+
normalized_user_agent = user_agent.lower().replace(" ", "-")
37+
if OTLP_EXPORTER_HEADER_IDENTIFIER in normalized_user_agent:
38+
return True
39+
return False
40+
41+
2742
def _extract_hostname_and_path(uri):
2843
# type: (str) -> str
2944
parsed_uri = parse.urlparse(uri)
@@ -67,7 +82,7 @@ def _wrap_send(func, instance, args, kwargs):
6782
return func(*args, **kwargs)
6883

6984
request = get_argument_value(args, kwargs, 0, "request")
70-
if not request:
85+
if not request or is_otlp_export(request):
7186
return func(*args, **kwargs)
7287

7388
url = _sanitized_url(request.url)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
DEFAULT_SERVICE = "requests"
2+
USER_AGENT_HEADER = "User-Agent"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
OTLP_EXPORTER_HEADER_IDENTIFIER = "otel-otlp-exporter-python"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
fixes:
3+
- |
4+
otel: Prevents OpenTelemetry OTLP exporter connections from being traced by ddtrace.
5+
ddtrace internal connections (gRPC and HTTP) are now excluded from tracing to prevent circular instrumentation.

tests/opentelemetry/test_logs.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,3 +504,62 @@ def test_otel_trace_log_correlation():
504504
assert (
505505
int(attributes["span_id"], 16) == span_context.span_id
506506
), f"Expected span_id_hex to be set to {attributes['span_id']} but found: {span_context.span_id}"
507+
508+
509+
@pytest.mark.skipif(
510+
EXPORTER_VERSION < MINIMUM_SUPPORTED_VERSION,
511+
reason=f"OpenTelemetry exporter version {MINIMUM_SUPPORTED_VERSION} is required to export logs",
512+
)
513+
@pytest.mark.snapshot()
514+
@pytest.mark.subprocess(ddtrace_run=True, env={"DD_LOGS_OTEL_ENABLED": "true"})
515+
def test_otel_logs_does_not_generate_client_grpc_spans():
516+
"""
517+
Test that OpenTelemetry grpc logs exporter does not generate client grpc spans.
518+
"""
519+
from logging import getLogger
520+
521+
from opentelemetry._logs import get_logger_provider
522+
523+
from tests.opentelemetry.test_logs import create_mock_grpc_server
524+
525+
logger = getLogger()
526+
mock_service, server = create_mock_grpc_server()
527+
528+
try:
529+
server.start()
530+
logger.error("test_otel_logs_grpc")
531+
get_logger_provider().force_flush()
532+
finally:
533+
server.stop(0)
534+
535+
assert mock_service.received_requests, "Expected gRPC log export requests but received none"
536+
537+
538+
@pytest.mark.skipif(
539+
EXPORTER_VERSION < MINIMUM_SUPPORTED_VERSION,
540+
reason=f"OpenTelemetry exporter version {MINIMUM_SUPPORTED_VERSION} is required to export logs",
541+
)
542+
@pytest.mark.snapshot()
543+
@pytest.mark.subprocess(
544+
ddtrace_run=True, env={"DD_LOGS_OTEL_ENABLED": "true", "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"}
545+
)
546+
def test_otel_logs_does_not_generate_client_http_spans():
547+
"""Test that OpenTelemetry http logs exporter does not generate client spans."""
548+
from logging import getLogger
549+
from unittest.mock import Mock
550+
from unittest.mock import patch
551+
552+
from opentelemetry._logs import get_logger_provider
553+
554+
logger = getLogger()
555+
with patch("requests.sessions.Session.request") as mock_request:
556+
mock_request.return_value = Mock(status_code=200)
557+
558+
logger.error("test_otel_logs_http")
559+
get_logger_provider().force_flush()
560+
561+
log_request_found = any(
562+
len(call[0]) >= 2 and call[0][0] == "POST" and "/v1/logs" in call[0][1]
563+
for call in mock_request.call_args_list
564+
)
565+
assert log_request_found, f"Expected HTTP log export request but found none: {mock_request.call_args_list}"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[[
2+
{
3+
"name": "grpc",
4+
"service": "grpc-server",
5+
"resource": "/opentelemetry.proto.collector.logs.v1.LogsService/Export",
6+
"trace_id": 0,
7+
"span_id": 1,
8+
"parent_id": 0,
9+
"type": "grpc",
10+
"error": 0,
11+
"meta": {
12+
"_dd.base_service": "ddtrace_subprocess_dir",
13+
"_dd.p.dm": "-0",
14+
"_dd.p.tid": "68f8d7df00000000",
15+
"component": "grpc_server",
16+
"grpc.method.kind": "unary",
17+
"grpc.method.name": "Export",
18+
"grpc.method.package": "opentelemetry.proto.collector.logs.v1",
19+
"grpc.method.path": "/opentelemetry.proto.collector.logs.v1.LogsService/Export",
20+
"grpc.method.service": "LogsService",
21+
"language": "python",
22+
"rpc.service": "opentelemetry.proto.collector.logs.v1.LogsService",
23+
"runtime-id": "f889b527dbdc40afa921bb54a08f8c90",
24+
"span.kind": "server"
25+
},
26+
"metrics": {
27+
"_dd.measured": 1,
28+
"_dd.top_level": 1,
29+
"_dd.tracer_kr": 1.0,
30+
"_sampling_priority_v1": 1,
31+
"process_id": 32526
32+
},
33+
"duration": 31000,
34+
"start": 1761138655401326000
35+
}]]

0 commit comments

Comments
 (0)