Skip to content

Commit 8b7479b

Browse files
committed
PYTHON-5945 Fix four command-span correctness bugs found in review
- db.query.text truncation reused a blind full-string slice, almost always producing invalid JSON; now truncates oversized field values first (matching the existing log-message truncation approach), with the blind slice only as a last-resort safety net. - explain wraps the real command ({"explain": {"find": ...}}), the same shape as getMore's indirection, but wasn't handled, so explain spans silently lost db.collection.name. - An explicit tracing.query_text_max_length=0 (meant to disable db.query.text) was indistinguishable from "not configured" and got silently overridden by the environment variable. The option is now Optional[int]: unset defers to the environment variable, any explicit value (including 0) always wins. - server.port was set to None for Unix domain socket connections (address[1] is None for .sock addresses), an invalid attribute type that the OTel SDK would warn about and drop; the attribute is now omitted instead. Also corrected _ConnectionTelemetryInfo.address's type to match reality (_Address, not a non-optional int port).
1 parent d6da2ad commit 8b7479b

8 files changed

Lines changed: 201 additions & 21 deletions

File tree

pymongo/_otel.py

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@
2626
from typing import TYPE_CHECKING, Any, Optional, TypedDict
2727

2828
from bson import json_util
29+
from bson.json_util import _truncate_documents
2930
from pymongo._version import __version__
30-
from pymongo.logger import _HELLO_COMMANDS, _SENSITIVE_COMMANDS
31+
from pymongo.logger import _HELLO_COMMANDS, _JSON_OPTIONS, _SENSITIVE_COMMANDS
3132

3233
try:
3334
from opentelemetry import trace
@@ -45,10 +46,15 @@
4546

4647

4748
class TracingOptions(TypedDict):
48-
"""The shape of the ``MongoClient`` ``tracing`` option."""
49+
"""The shape of the ``MongoClient`` ``tracing`` option.
50+
51+
``query_text_max_length`` is None when the client didn't configure it, so
52+
the environment variable can be consulted; any explicit value (including
53+
0, to force ``db.query.text`` off) overrides the environment variable.
54+
"""
4955

5056
enabled: bool
51-
query_text_max_length: int
57+
query_text_max_length: Optional[int]
5258

5359

5460
_OTEL_ENABLED_ENV = "OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED"
@@ -64,6 +70,11 @@ class TracingOptions(TypedDict):
6470
# See _gen_get_more_command in pymongo/message.py.
6571
_GET_MORE = "getMore"
6672

73+
# explain wraps the real command (e.g. find/aggregate) rather than naming a
74+
# collection directly: {"explain": {"find": "coll", ...}}. See _Query.as_command
75+
# in pymongo/message.py.
76+
_EXPLAIN = "explain"
77+
6778
# Commands against this database (e.g. user/role management, renameCollection)
6879
# never have a real collection name, even when their command value is a string.
6980
_ADMIN_DB = "admin"
@@ -94,21 +105,35 @@ def _get_tracer() -> Tracer:
94105

95106

96107
def _get_query_text_max_length(tracing_options: Optional[TracingOptions]) -> int:
97-
"""Return the configured db.query.text truncation length, or 0 to omit the attribute."""
98-
client_value = tracing_options.get("query_text_max_length", 0) if tracing_options else 0
99-
if client_value > 0:
100-
return client_value
108+
"""Return the configured db.query.text truncation length, or 0 to omit the attribute.
109+
110+
An explicit client value (including 0) always wins; the environment
111+
variable is only consulted when the client didn't configure it at all.
112+
"""
113+
client_value = tracing_options.get("query_text_max_length") if tracing_options else None
114+
if client_value is not None:
115+
return max(0, client_value)
101116
try:
102117
return max(0, int(os.getenv(_OTEL_QUERY_TEXT_MAX_LENGTH_ENV, "0")))
103118
except ValueError:
104119
return 0
105120

106121

107122
def _build_query_text(cmd: Mapping[str, Any], max_length: int) -> str:
108-
"""Serialize ``cmd`` to extended JSON, redacted and truncated to ``max_length``."""
123+
"""Serialize ``cmd`` to extended JSON, redacted and truncated to ``max_length``.
124+
125+
Mirrors the truncation approach used for log messages: truncate field
126+
values first, which usually keeps the result well-formed JSON (unlike a
127+
blind cut of the fully-serialized string), then fall back to a hard
128+
string cut with a "..." marker as a safety net for whatever the field
129+
truncation's size estimate still leaves over ``max_length``.
130+
"""
109131
filtered = {k: v for k, v in cmd.items() if k not in _QUERY_TEXT_EXCLUDED_FIELDS}
110-
text = json_util.dumps(filtered)
111-
return text[:max_length]
132+
truncated_cmd = _truncate_documents(filtered, max_length)[0]
133+
text = json_util.dumps(truncated_cmd, json_options=_JSON_OPTIONS)
134+
if len(text) > max_length:
135+
text = text[:max_length] + "..."
136+
return text
112137

113138

114139
def _extract_collection_name(
@@ -122,6 +147,12 @@ def _extract_collection_name(
122147
"""
123148
if dbname == _ADMIN_DB:
124149
return None
150+
if command_name == _EXPLAIN:
151+
inner = cmd.get(_EXPLAIN)
152+
if not isinstance(inner, Mapping) or not inner:
153+
return None
154+
inner_name = next(iter(inner))
155+
return _extract_collection_name(inner_name, dbname, inner)
125156
key = "collection" if command_name == _GET_MORE else command_name
126157
value = cmd.get(key)
127158
return value if isinstance(value, str) else None
@@ -178,10 +209,12 @@ def start_span(
178209
"db.command.name": command_name,
179210
"db.query.summary": _build_query_summary(command_name, dbname, collection),
180211
"server.address": address[0],
181-
"server.port": address[1],
182212
"network.transport": "unix" if address[0].endswith(".sock") else "tcp",
183213
"db.mongodb.driver_connection_id": conn.id,
184214
}
215+
# Unix domain socket addresses have no port.
216+
if address[1] is not None:
217+
attributes["server.port"] = address[1]
185218
if collection:
186219
attributes["db.collection.name"] = collection
187220
if conn.server_connection_id is not None:

pymongo/asynchronous/mongo_client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -625,9 +625,11 @@ def __init__(
625625
``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either
626626
being enabled is sufficient.
627627
- ``query_text_max_length``: (int) The maximum length of the ``db.query.text``
628-
span attribute. Defaults to ``0``, which omits the attribute. Also controlled
629-
by the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH``
630-
environment variable.
628+
span attribute. Unset by default, which defers to the
629+
``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment
630+
variable (itself defaulting to ``0``, which omits the attribute). Setting
631+
this explicitly, including to ``0``, always overrides the environment
632+
variable.
631633
632634
.. seealso:: The MongoDB documentation on `connections <https://dochub.mongodb.org/core/connections>`_.
633635

pymongo/client_options.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def __init__(
250250
)
251251
self.__tracing = cast(
252252
"_otel.TracingOptions",
253-
options.get("tracing") or {"enabled": False, "query_text_max_length": 0},
253+
options.get("tracing") or {"enabled": False, "query_text_max_length": None},
254254
)
255255

256256
@property

pymongo/common.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,9 @@ def validate_tracing_or_none(option: str, value: Any) -> Optional[_otel.TracingO
618618
raise ConfigurationError(f"Unknown tracing option(s): {sorted(unknown)}")
619619
enabled = value.get("enabled", False)
620620
validate_boolean("tracing.enabled", enabled)
621-
query_text_max_length = value.get("query_text_max_length", 0)
622-
validate_non_negative_integer("tracing.query_text_max_length", query_text_max_length)
621+
query_text_max_length = value.get("query_text_max_length")
622+
if query_text_max_length is not None:
623+
validate_non_negative_integer("tracing.query_text_max_length", query_text_max_length)
623624
return {"enabled": enabled, "query_text_max_length": query_text_max_length}
624625

625626

pymongo/pool_shared.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class _ConnectionTelemetryInfo(Protocol):
5555

5656
id: int
5757
server_connection_id: Optional[int]
58-
address: tuple[str, int]
58+
address: _Address
5959
service_id: Optional[ObjectId]
6060

6161

pymongo/synchronous/mongo_client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -626,9 +626,11 @@ def __init__(
626626
``OTEL_PYTHON_INSTRUMENTATION_MONGODB_ENABLED`` environment variable; either
627627
being enabled is sufficient.
628628
- ``query_text_max_length``: (int) The maximum length of the ``db.query.text``
629-
span attribute. Defaults to ``0``, which omits the attribute. Also controlled
630-
by the ``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH``
631-
environment variable.
629+
span attribute. Unset by default, which defers to the
630+
``OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH`` environment
631+
variable (itself defaulting to ``0``, which omits the attribute). Setting
632+
this explicitly, including to ``0``, always overrides the environment
633+
variable.
632634
633635
.. seealso:: The MongoDB documentation on `connections <https://dochub.mongodb.org/core/connections>`_.
634636

test/asynchronous/test_otel.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import os
2020
import sys
21+
from typing import Optional
2122
from unittest.mock import patch
2223

2324
sys.path[0:0] = [""]
@@ -26,6 +27,7 @@
2627

2728
import pymongo._otel as _otel
2829
from pymongo.errors import OperationFailure
30+
from pymongo.typings import _Address
2931
from test.asynchronous import AsyncIntegrationTest, unittest
3032

3133
if _otel._HAS_OPENTELEMETRY:
@@ -116,6 +118,43 @@ async def test_span_created_for_get_more(self):
116118
self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore")
117119
self.assertEqual(span.attributes["db.command.name"], "getMore")
118120

121+
async def test_explain_retains_collection_name(self):
122+
# explain wraps the real command ({"explain": {"find": "coll", ...}}), the
123+
# same shape as getMore's indirection, so it needs the same handling.
124+
client = await self.async_rs_or_single_client(tracing={"enabled": True})
125+
self.exporter.clear()
126+
await client[self.db.name].command("explain", {"find": "test_otel", "filter": {}})
127+
128+
spans = self.spans("explain")
129+
self.assertEqual(len(spans), 1)
130+
attrs = spans[0].attributes
131+
self.assertEqual(attrs["db.collection.name"], "test_otel")
132+
self.assertEqual(attrs["db.query.summary"], f"explain {self.db.name}.test_otel")
133+
134+
async def test_server_port_omitted_for_unix_socket(self):
135+
class _FakeUnixConn:
136+
id = 1
137+
server_connection_id: Optional[int] = None
138+
address: _Address = ("/tmp/fake-otel-test.sock", None)
139+
service_id = None
140+
141+
self.exporter.clear()
142+
span = _otel.start_span(
143+
{"enabled": True, "query_text_max_length": None},
144+
_FakeUnixConn(),
145+
{"ping": 1},
146+
"admin",
147+
"ping",
148+
False,
149+
)
150+
_otel.end_span_success(span, {"ok": 1})
151+
152+
spans = self.spans("ping")
153+
self.assertEqual(len(spans), 1)
154+
attrs = spans[0].attributes
155+
self.assertNotIn("server.port", attrs)
156+
self.assertEqual(attrs["network.transport"], "unix")
157+
119158
async def test_sensitive_command_produces_no_span(self):
120159
client = await self.async_rs_or_single_client(tracing={"enabled": True})
121160
self.exporter.clear()
@@ -208,6 +247,40 @@ async def test_query_text_included_when_configured(self):
208247
self.assertIn("db.query.text", spans[0].attributes)
209248
self.assertNotIn("lsid", spans[0].attributes["db.query.text"])
210249

250+
async def test_explicit_query_text_max_length_zero_overrides_env_var(self):
251+
# An explicit client-side 0 must win over the environment variable, unlike
252+
# unset (which defers to it) - otherwise an app can't reliably opt out.
253+
env = {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024"}
254+
with patch.dict(os.environ, env):
255+
client = await self.async_rs_or_single_client(
256+
tracing={"enabled": True, "query_text_max_length": 0}
257+
)
258+
self.exporter.clear()
259+
await client.admin.command("ping")
260+
261+
spans = self.spans("ping")
262+
self.assertEqual(len(spans), 1)
263+
self.assertNotIn("db.query.text", spans[0].attributes)
264+
265+
async def test_query_text_truncation_shrinks_oversized_field_values(self):
266+
client = await self.async_rs_or_single_client(
267+
tracing={"enabled": True, "query_text_max_length": 200}
268+
)
269+
coll = client[self.db.name].test_otel
270+
await coll.drop()
271+
self.exporter.clear()
272+
await coll.insert_one({"x": "a" * 500})
273+
274+
spans = self.spans("insert")
275+
self.assertEqual(len(spans), 1)
276+
query_text = spans[0].attributes["db.query.text"]
277+
# The oversized field value must be truncated at the field level (not
278+
# just a blind cut of the fully-serialized string), keeping the result
279+
# close to the configured bound; a "..." marker signals the (possible)
280+
# safety-net cut, mirroring the driver's existing log-truncation approach.
281+
self.assertLessEqual(len(query_text), 200 + len("..."))
282+
self.assertNotIn("a" * 500, query_text)
283+
211284

212285
if __name__ == "__main__":
213286
unittest.main()

test/test_otel.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import os
2020
import sys
21+
from typing import Optional
2122
from unittest.mock import patch
2223

2324
sys.path[0:0] = [""]
@@ -26,6 +27,7 @@
2627

2728
import pymongo._otel as _otel
2829
from pymongo.errors import OperationFailure
30+
from pymongo.typings import _Address
2931
from test import IntegrationTest, unittest
3032

3133
if _otel._HAS_OPENTELEMETRY:
@@ -116,6 +118,43 @@ def test_span_created_for_get_more(self):
116118
self.assertEqual(span.attributes["db.collection.name"], "test_otel_getmore")
117119
self.assertEqual(span.attributes["db.command.name"], "getMore")
118120

121+
def test_explain_retains_collection_name(self):
122+
# explain wraps the real command ({"explain": {"find": "coll", ...}}), the
123+
# same shape as getMore's indirection, so it needs the same handling.
124+
client = self.rs_or_single_client(tracing={"enabled": True})
125+
self.exporter.clear()
126+
client[self.db.name].command("explain", {"find": "test_otel", "filter": {}})
127+
128+
spans = self.spans("explain")
129+
self.assertEqual(len(spans), 1)
130+
attrs = spans[0].attributes
131+
self.assertEqual(attrs["db.collection.name"], "test_otel")
132+
self.assertEqual(attrs["db.query.summary"], f"explain {self.db.name}.test_otel")
133+
134+
def test_server_port_omitted_for_unix_socket(self):
135+
class _FakeUnixConn:
136+
id = 1
137+
server_connection_id: Optional[int] = None
138+
address: _Address = ("/tmp/fake-otel-test.sock", None)
139+
service_id = None
140+
141+
self.exporter.clear()
142+
span = _otel.start_span(
143+
{"enabled": True, "query_text_max_length": None},
144+
_FakeUnixConn(),
145+
{"ping": 1},
146+
"admin",
147+
"ping",
148+
False,
149+
)
150+
_otel.end_span_success(span, {"ok": 1})
151+
152+
spans = self.spans("ping")
153+
self.assertEqual(len(spans), 1)
154+
attrs = spans[0].attributes
155+
self.assertNotIn("server.port", attrs)
156+
self.assertEqual(attrs["network.transport"], "unix")
157+
119158
def test_sensitive_command_produces_no_span(self):
120159
client = self.rs_or_single_client(tracing={"enabled": True})
121160
self.exporter.clear()
@@ -206,6 +245,36 @@ def test_query_text_included_when_configured(self):
206245
self.assertIn("db.query.text", spans[0].attributes)
207246
self.assertNotIn("lsid", spans[0].attributes["db.query.text"])
208247

248+
def test_explicit_query_text_max_length_zero_overrides_env_var(self):
249+
# An explicit client-side 0 must win over the environment variable, unlike
250+
# unset (which defers to it) - otherwise an app can't reliably opt out.
251+
env = {"OTEL_PYTHON_INSTRUMENTATION_MONGODB_QUERY_TEXT_MAX_LENGTH": "1024"}
252+
with patch.dict(os.environ, env):
253+
client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 0})
254+
self.exporter.clear()
255+
client.admin.command("ping")
256+
257+
spans = self.spans("ping")
258+
self.assertEqual(len(spans), 1)
259+
self.assertNotIn("db.query.text", spans[0].attributes)
260+
261+
def test_query_text_truncation_shrinks_oversized_field_values(self):
262+
client = self.rs_or_single_client(tracing={"enabled": True, "query_text_max_length": 200})
263+
coll = client[self.db.name].test_otel
264+
coll.drop()
265+
self.exporter.clear()
266+
coll.insert_one({"x": "a" * 500})
267+
268+
spans = self.spans("insert")
269+
self.assertEqual(len(spans), 1)
270+
query_text = spans[0].attributes["db.query.text"]
271+
# The oversized field value must be truncated at the field level (not
272+
# just a blind cut of the fully-serialized string), keeping the result
273+
# close to the configured bound; a "..." marker signals the (possible)
274+
# safety-net cut, mirroring the driver's existing log-truncation approach.
275+
self.assertLessEqual(len(query_text), 200 + len("..."))
276+
self.assertNotIn("a" * 500, query_text)
277+
209278

210279
if __name__ == "__main__":
211280
unittest.main()

0 commit comments

Comments
 (0)