From a2bee9cd2e879cf602eba38fbc96adaf951b80ea Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 19 Aug 2026 15:16:32 -0500 Subject: [PATCH 01/10] PYTHON-5947 Add error.type OpenTelemetry command span attribute Emit error.type on command spans, mirroring db.response.status_code for server errors and falling back to the exception class name otherwise. Operation spans deliberately do not carry error.type, per the spec. Syncs the new open-telemetry error_type.json spec fixture (both the server-error and non-server-error cases). --- pymongo/_otel.py | 15 +- test/open_telemetry/operation/error_type.json | 267 ++++++++++++++++++ 2 files changed, 279 insertions(+), 3 deletions(-) create mode 100644 test/open_telemetry/operation/error_type.json diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 009196fad1..7f0308a596 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -450,12 +450,14 @@ def end_command_span_success(span: Optional[Span], reply: _DocumentOut) -> None: span.end() -def _set_exception_attributes(span: Span, exc: BaseException) -> None: +def _set_exception_attributes(span: Span, exc: BaseException) -> str: """Set exception.type/exception.message/exception.stacktrace span attributes. ``record_exception`` attaches these to an "exception" *event* only, but the spec requires them as span *attributes* too, for both command and operation - spans. Formatting mirrors ``record_exception``. + spans. Formatting mirrors ``record_exception``. Returns the computed + ``exception.type`` value so callers (e.g. ``error.type``) can reuse it + without recomputing. """ module = type(exc).__module__ qualname = type(exc).__qualname__ @@ -466,6 +468,7 @@ def _set_exception_attributes(span: Span, exc: BaseException) -> None: "exception.stacktrace", "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)), ) + return exception_type def end_command_span_failure( @@ -478,10 +481,16 @@ def end_command_span_failure( return try: span.record_exception(exc) - _set_exception_attributes(span, exc) + exception_type = _set_exception_attributes(span, exc) code = failure.get("code") if code is not None: + # Server error: error.type mirrors db.response.status_code, per spec. span.set_attribute("db.response.status_code", str(code)) + span.set_attribute("error.type", str(code)) + else: + # Non-server error (e.g. network failure): fall back to the + # exception's class name, since there's no server error code to report. + span.set_attribute("error.type", exception_type) span.set_status(Status(StatusCode.ERROR, description=failure.get("errmsg"))) finally: # End even if recording raised, so a failure here costs the attributes diff --git a/test/open_telemetry/operation/error_type.json b/test/open_telemetry/operation/error_type.json new file mode 100644 index 0000000000..8b9bf8a42a --- /dev/null +++ b/test/open_telemetry/operation/error_type.json @@ -0,0 +1,267 @@ +{ + "description": "error_type", + "schemaVersion": "1.27", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "uriOptions": { + "retryReads": false + }, + "observeTracingMessages": { + "enableCommandPayload": false + } + } + }, + { + "client": { + "id": "failPointClient", + "useMultipleMongoses": false + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "operation-error-type" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "test" + } + } + ], + "initialData": [ + { + "collectionName": "test", + "databaseName": "operation-error-type", + "documents": [] + } + ], + "tests": [ + { + "description": "error.type matches db.response.status_code for a server error", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "errorCode": 8 + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + }, + "expectError": { + "isError": true + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-error-type.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-error-type.test", + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "error.type": { + "$$exists": false + } + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": "8", + "error.type": "8", + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + }, + { + "description": "error.type falls back to the exception class name for a non-server error", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + }, + "expectError": { + "isError": true + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-error-type.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-error-type.test", + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "error.type": { + "$$exists": false + } + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "error.type": { + "$$type": "string" + }, + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + } + ] +} From 7bea9b8bfb9cf138905b760d0adcb741175c13f6 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 19 Aug 2026 15:49:04 -0500 Subject: [PATCH 02/10] PYTHON-5947 Test error.type class-name fallback for network errors Covers the two non-server-error paths where there is no server error code to mirror: a closed connection (ConnectionFailure) and a socket timeout (NetworkTimeout). Both assert error.type is the exception's qualified class name and that db.response.status_code is absent. --- test/asynchronous/test_otel.py | 61 ++++++++++++++++++++++++++++++++++ test/test_otel.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index db2b6275df..d00bb948cf 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -36,7 +36,9 @@ from pymongo.errors import ( ClientBulkWriteException, ConfigurationError, + ConnectionFailure, InvalidOperation, + NetworkTimeout, OperationFailure, ServerSelectionTimeoutError, ) @@ -77,6 +79,11 @@ def test_result_never_exceeds_max_length(self): self.assertLessEqual(len(text), max_length, (max_length, text)) +def _qualified_name(exc_type: type) -> str: + """Format an exception class the way the spans do: ``module.QualName``.""" + return f"{exc_type.__module__}.{exc_type.__qualname__}" + + @unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") class TestOTelOperationSpanPrimitives(unittest.TestCase): """Unit tests for the pymongo._otel operation-span primitives.""" @@ -563,8 +570,62 @@ async def test_failure_records_exception_and_status_code(self): span = spans[0] self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) self.assertIn("db.response.status_code", span.attributes) + # For a server error the spec has error.type mirror the status code. + self.assertEqual(span.attributes["error.type"], span.attributes["db.response.status_code"]) self.assertTrue(any(event.name == "exception" for event in span.events)) + @async_client_context.require_failCommand_fail_point + async def test_error_type_is_exception_class_name_for_connection_failure(self): + # A closed connection produces no server reply, so there is no error + # code to report: the spec falls back to the exception's class name. + client = await self.async_rs_or_single_client(tracing={"enabled": True}, retryReads=False) + fail_command = { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "closeConnection": True}, + } + async with self.fail_point(fail_command): + self.exporter.clear() + with self.assertRaises(ConnectionFailure) as ctx: + await client[self.db.name].test.find_one({}) + + spans = [s for s in self.spans() if s.attributes.get("db.command.name") == "find"] + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("db.response.status_code", attrs) + self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) + # error.type and exception.type carry the same value here, by design: + # one is the span attribute, the other the exception event's. + self.assertEqual(attrs["error.type"], attrs["exception.type"]) + + @async_client_context.require_failCommand_blockConnection + async def test_error_type_is_exception_class_name_for_network_timeout(self): + # socketTimeoutMS trips before the blocked command replies, so again + # there is no server error code and error.type is the class name. + client = await self.async_rs_or_single_client( + tracing={"enabled": True}, socketTimeoutMS=200, retryReads=False + ) + fail_command = { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": { + "failCommands": ["find"], + "blockConnection": True, + "blockTimeMS": 1000, + }, + } + async with self.fail_point(fail_command): + self.exporter.clear() + with self.assertRaises(NetworkTimeout) as ctx: + await client[self.db.name].test.find_one({}) + + spans = [s for s in self.spans() if s.attributes.get("db.command.name") == "find"] + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("db.response.status_code", attrs) + self.assertEqual(attrs["error.type"], _qualified_name(NetworkTimeout)) + self.assertIsInstance(ctx.exception, NetworkTimeout) + async def test_tracing_disabled_by_default(self): client = await self.async_rs_or_single_client() self.exporter.clear() diff --git a/test/test_otel.py b/test/test_otel.py index d5770bc3d6..060fd95ab7 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -48,7 +48,9 @@ from pymongo.errors import ( ClientBulkWriteException, ConfigurationError, + ConnectionFailure, InvalidOperation, + NetworkTimeout, OperationFailure, ServerSelectionTimeoutError, ) @@ -96,6 +98,11 @@ def test_result_never_exceeds_max_length(self): self.assertLessEqual(len(text), max_length, (max_length, text)) +def _qualified_name(exc_type: type) -> str: + """Format an exception class the way the spans do: ``module.QualName``.""" + return f"{exc_type.__module__}.{exc_type.__qualname__}" + + @unittest.skipUnless(_HAS_OTEL_TEST_DEPS, "opentelemetry-sdk is not installed") class TestOTelOperationSpanPrimitives(unittest.TestCase): """Unit tests for the pymongo._otel operation-span primitives.""" @@ -582,8 +589,62 @@ def test_failure_records_exception_and_status_code(self): span = spans[0] self.assertEqual(span.status.status_code, trace.StatusCode.ERROR) self.assertIn("db.response.status_code", span.attributes) + # For a server error the spec has error.type mirror the status code. + self.assertEqual(span.attributes["error.type"], span.attributes["db.response.status_code"]) self.assertTrue(any(event.name == "exception" for event in span.events)) + @client_context.require_failCommand_fail_point + def test_error_type_is_exception_class_name_for_connection_failure(self): + # A closed connection produces no server reply, so there is no error + # code to report: the spec falls back to the exception's class name. + client = self.rs_or_single_client(tracing={"enabled": True}, retryReads=False) + fail_command = { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "closeConnection": True}, + } + with self.fail_point(fail_command): + self.exporter.clear() + with self.assertRaises(ConnectionFailure) as ctx: + client[self.db.name].test.find_one({}) + + spans = [s for s in self.spans() if s.attributes.get("db.command.name") == "find"] + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("db.response.status_code", attrs) + self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) + # error.type and exception.type carry the same value here, by design: + # one is the span attribute, the other the exception event's. + self.assertEqual(attrs["error.type"], attrs["exception.type"]) + + @client_context.require_failCommand_blockConnection + def test_error_type_is_exception_class_name_for_network_timeout(self): + # socketTimeoutMS trips before the blocked command replies, so again + # there is no server error code and error.type is the class name. + client = self.rs_or_single_client( + tracing={"enabled": True}, socketTimeoutMS=200, retryReads=False + ) + fail_command = { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": { + "failCommands": ["find"], + "blockConnection": True, + "blockTimeMS": 1000, + }, + } + with self.fail_point(fail_command): + self.exporter.clear() + with self.assertRaises(NetworkTimeout) as ctx: + client[self.db.name].test.find_one({}) + + spans = [s for s in self.spans() if s.attributes.get("db.command.name") == "find"] + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertNotIn("db.response.status_code", attrs) + self.assertEqual(attrs["error.type"], _qualified_name(NetworkTimeout)) + self.assertIsInstance(ctx.exception, NetworkTimeout) + def test_tracing_disabled_by_default(self): client = self.rs_or_single_client() self.exporter.clear() From 405610f7809eba7a32c90dc1d254546c03a914ec Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 21 Aug 2026 10:51:42 -0500 Subject: [PATCH 03/10] PYTHON-6045 Trim the error.type comments and docstring Cut each to the invariant a reader needs, dropping restatements of the code. --- pymongo/_otel.py | 10 ++++------ test/asynchronous/test_otel.py | 10 ++++------ test/test_otel.py | 10 ++++------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 7f0308a596..48fd54b6f3 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -455,9 +455,9 @@ def _set_exception_attributes(span: Span, exc: BaseException) -> str: ``record_exception`` attaches these to an "exception" *event* only, but the spec requires them as span *attributes* too, for both command and operation - spans. Formatting mirrors ``record_exception``. Returns the computed - ``exception.type`` value so callers (e.g. ``error.type``) can reuse it - without recomputing. + spans. Formatting mirrors ``record_exception``. + + :return: The ``exception.type`` value. """ module = type(exc).__module__ qualname = type(exc).__qualname__ @@ -484,12 +484,10 @@ def end_command_span_failure( exception_type = _set_exception_attributes(span, exc) code = failure.get("code") if code is not None: - # Server error: error.type mirrors db.response.status_code, per spec. span.set_attribute("db.response.status_code", str(code)) span.set_attribute("error.type", str(code)) else: - # Non-server error (e.g. network failure): fall back to the - # exception's class name, since there's no server error code to report. + # A network failure gets no server reply, so there is no code to report. span.set_attribute("error.type", exception_type) span.set_status(Status(StatusCode.ERROR, description=failure.get("errmsg"))) finally: diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index d00bb948cf..385b1dc430 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -576,8 +576,8 @@ async def test_failure_records_exception_and_status_code(self): @async_client_context.require_failCommand_fail_point async def test_error_type_is_exception_class_name_for_connection_failure(self): - # A closed connection produces no server reply, so there is no error - # code to report: the spec falls back to the exception's class name. + # A closed connection produces no server reply, so error.type falls back + # to the exception's class name. client = await self.async_rs_or_single_client(tracing={"enabled": True}, retryReads=False) fail_command = { "configureFailPoint": "failCommand", @@ -594,14 +594,12 @@ async def test_error_type_is_exception_class_name_for_connection_failure(self): attrs = spans[0].attributes self.assertNotIn("db.response.status_code", attrs) self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) - # error.type and exception.type carry the same value here, by design: - # one is the span attribute, the other the exception event's. + # error.type and exception.type carry the same value on this path. self.assertEqual(attrs["error.type"], attrs["exception.type"]) @async_client_context.require_failCommand_blockConnection async def test_error_type_is_exception_class_name_for_network_timeout(self): - # socketTimeoutMS trips before the blocked command replies, so again - # there is no server error code and error.type is the class name. + # socketTimeoutMS trips before any reply, so again no server error code. client = await self.async_rs_or_single_client( tracing={"enabled": True}, socketTimeoutMS=200, retryReads=False ) diff --git a/test/test_otel.py b/test/test_otel.py index 060fd95ab7..db502a074f 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -595,8 +595,8 @@ def test_failure_records_exception_and_status_code(self): @client_context.require_failCommand_fail_point def test_error_type_is_exception_class_name_for_connection_failure(self): - # A closed connection produces no server reply, so there is no error - # code to report: the spec falls back to the exception's class name. + # A closed connection produces no server reply, so error.type falls back + # to the exception's class name. client = self.rs_or_single_client(tracing={"enabled": True}, retryReads=False) fail_command = { "configureFailPoint": "failCommand", @@ -613,14 +613,12 @@ def test_error_type_is_exception_class_name_for_connection_failure(self): attrs = spans[0].attributes self.assertNotIn("db.response.status_code", attrs) self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) - # error.type and exception.type carry the same value here, by design: - # one is the span attribute, the other the exception event's. + # error.type and exception.type carry the same value on this path. self.assertEqual(attrs["error.type"], attrs["exception.type"]) @client_context.require_failCommand_blockConnection def test_error_type_is_exception_class_name_for_network_timeout(self): - # socketTimeoutMS trips before the blocked command replies, so again - # there is no server error code and error.type is the class name. + # socketTimeoutMS trips before any reply, so again no server error code. client = self.rs_or_single_client( tracing={"enabled": True}, socketTimeoutMS=200, retryReads=False ) From d4f64677ad9e60ea1955df6590b31845a2ae3ab7 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 2 Sep 2026 06:47:47 -0500 Subject: [PATCH 04/10] PYTHON-6045 Tighten the error.type test comments --- test/asynchronous/test_otel.py | 6 ++---- test/test_otel.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 385b1dc430..857178ee4d 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -576,8 +576,7 @@ async def test_failure_records_exception_and_status_code(self): @async_client_context.require_failCommand_fail_point async def test_error_type_is_exception_class_name_for_connection_failure(self): - # A closed connection produces no server reply, so error.type falls back - # to the exception's class name. + # A closed connection produces no server reply, so error.type uses the class name. client = await self.async_rs_or_single_client(tracing={"enabled": True}, retryReads=False) fail_command = { "configureFailPoint": "failCommand", @@ -594,12 +593,11 @@ async def test_error_type_is_exception_class_name_for_connection_failure(self): attrs = spans[0].attributes self.assertNotIn("db.response.status_code", attrs) self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) - # error.type and exception.type carry the same value on this path. self.assertEqual(attrs["error.type"], attrs["exception.type"]) @async_client_context.require_failCommand_blockConnection async def test_error_type_is_exception_class_name_for_network_timeout(self): - # socketTimeoutMS trips before any reply, so again no server error code. + # socketTimeoutMS trips before any reply, so there is no server error code. client = await self.async_rs_or_single_client( tracing={"enabled": True}, socketTimeoutMS=200, retryReads=False ) diff --git a/test/test_otel.py b/test/test_otel.py index db502a074f..3bdc8d8eee 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -595,8 +595,7 @@ def test_failure_records_exception_and_status_code(self): @client_context.require_failCommand_fail_point def test_error_type_is_exception_class_name_for_connection_failure(self): - # A closed connection produces no server reply, so error.type falls back - # to the exception's class name. + # A closed connection produces no server reply, so error.type uses the class name. client = self.rs_or_single_client(tracing={"enabled": True}, retryReads=False) fail_command = { "configureFailPoint": "failCommand", @@ -613,12 +612,11 @@ def test_error_type_is_exception_class_name_for_connection_failure(self): attrs = spans[0].attributes self.assertNotIn("db.response.status_code", attrs) self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) - # error.type and exception.type carry the same value on this path. self.assertEqual(attrs["error.type"], attrs["exception.type"]) @client_context.require_failCommand_blockConnection def test_error_type_is_exception_class_name_for_network_timeout(self): - # socketTimeoutMS trips before any reply, so again no server error code. + # socketTimeoutMS trips before any reply, so there is no server error code. client = self.rs_or_single_client( tracing={"enabled": True}, socketTimeoutMS=200, retryReads=False ) From 9d66defe1b2782d9387b30db24b658faacae6598 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 3 Sep 2026 06:18:21 -0500 Subject: [PATCH 05/10] PYTHON-6045 Sync error_type spec fixture: success and retry-success cases Adds the two cases added since this branch last synced: a successful command asserting error.type stays off both spans, and a failed retry whose overall operation succeeds, asserting error.type never reaches the operation span. The upstream fixture was also reworked to a single observeTracingMessages client, since this runner does not support more than one (see test/unified_format.py's create_entities_from_spec). --- test/open_telemetry/operation/error_type.json | 279 +++++++++++++++++- 1 file changed, 275 insertions(+), 4 deletions(-) diff --git a/test/open_telemetry/operation/error_type.json b/test/open_telemetry/operation/error_type.json index 8b9bf8a42a..e046542a0b 100644 --- a/test/open_telemetry/operation/error_type.json +++ b/test/open_telemetry/operation/error_type.json @@ -6,9 +6,6 @@ "client": { "id": "client0", "useMultipleMongoses": false, - "uriOptions": { - "retryReads": false - }, "observeTracingMessages": { "enableCommandPayload": false } @@ -165,7 +162,7 @@ "failPoint": { "configureFailPoint": "failCommand", "mode": { - "times": 1 + "times": 2 }, "data": { "failCommands": [ @@ -256,6 +253,280 @@ ] } } + }, + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "error.type": { + "$$type": "string" + }, + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + }, + { + "description": "error.type is absent when the command succeeds", + "operations": [ + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-error-type.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-error-type.test", + "error.type": { + "$$exists": false + } + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "error.type": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] + }, + { + "description": "error.type on a failed retried command does not appear on the operation span", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "errorCode": 89, + "errorLabels": [ + "RetryableWriteError" + ] + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-error-type.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-error-type.test", + "error.type": { + "$$exists": false + } + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": "89", + "error.type": "89", + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + }, + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "error.type": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } } ] } From 7c22b57199add56dd8b1aa3546389cce7c83b72f Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 3 Sep 2026 06:26:20 -0500 Subject: [PATCH 06/10] PYTHON-6045 Sync error_type spec fixture: retried non-server error case Adds the fifth case: a command fails with a network error, the retry succeeds, and no exception ever reaches the application. Confirms end_command_span_failure already sets error.type from the per-attempt exception rather than one the caller sees. --- test/open_telemetry/operation/error_type.json | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/test/open_telemetry/operation/error_type.json b/test/open_telemetry/operation/error_type.json index e046542a0b..142ddab05d 100644 --- a/test/open_telemetry/operation/error_type.json +++ b/test/open_telemetry/operation/error_type.json @@ -533,6 +533,150 @@ ] } ] + }, + { + "description": "error.type on a failed retried non-server command is not raised to the application", + "operations": [ + { + "name": "failPoint", + "object": "testRunner", + "arguments": { + "client": "failPointClient", + "failPoint": { + "configureFailPoint": "failCommand", + "mode": { + "times": 1 + }, + "data": { + "failCommands": [ + "find" + ], + "closeConnection": true + } + } + } + }, + { + "name": "find", + "object": "collection0", + "arguments": { + "filter": { + "x": 1 + } + } + } + ], + "expectTracingMessages": [ + { + "client": "client0", + "ignoreExtraSpans": true, + "spans": [ + { + "name": "find operation-error-type.test", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.operation.name": "find", + "db.operation.summary": "find operation-error-type.test", + "error.type": { + "$$exists": false + } + }, + "nested": [ + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "error.type": { + "$$type": "string" + }, + "exception.message": { + "$$type": "string" + }, + "exception.type": { + "$$type": "string" + }, + "exception.stacktrace": { + "$$type": "string" + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + }, + { + "name": "find", + "attributes": { + "db.system.name": "mongodb", + "db.namespace": "operation-error-type", + "db.collection.name": "test", + "db.command.name": "find", + "network.transport": "tcp", + "db.response.status_code": { + "$$exists": false + }, + "error.type": { + "$$exists": false + }, + "exception.message": { + "$$exists": false + }, + "exception.type": { + "$$exists": false + }, + "exception.stacktrace": { + "$$exists": false + }, + "server.address": { + "$$type": "string" + }, + "server.port": { + "$$type": [ + "long", + "string" + ] + }, + "db.query.summary": "find operation-error-type.test", + "db.mongodb.server_connection_id": { + "$$type": [ + "int", + "long" + ] + }, + "db.mongodb.driver_connection_id": { + "$$type": [ + "int", + "long" + ] + } + } + } + ] + } + ] + } + ] } ] } From 21d51b3992069d7fdef64a0a7fd25f3d051b4443 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 3 Sep 2026 06:39:38 -0500 Subject: [PATCH 07/10] PYTHON-6045 Set error.type on operation spans when the operation itself fails Per DRIVERS-3617's updated spec: operation spans still omit error.type when a failed command's retry saves the operation, but now carry it, matching exception.type, when the operation itself ultimately fails. --- pymongo/_otel.py | 3 ++- test/open_telemetry/operation/error_type.json | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pymongo/_otel.py b/pymongo/_otel.py index 48fd54b6f3..a88f8bbbd8 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -636,7 +636,8 @@ def end_operation_span_failure(handle: Optional[_OperationSpanHandle], exc: Base return try: handle.span.record_exception(exc) - _set_exception_attributes(handle.span, exc) + exception_type = _set_exception_attributes(handle.span, exc) + handle.span.set_attribute("error.type", exception_type) handle.span.set_status(Status(StatusCode.ERROR, description=str(exc))) finally: # Unwind even if recording raised, since a span left current would diff --git a/test/open_telemetry/operation/error_type.json b/test/open_telemetry/operation/error_type.json index 142ddab05d..50faa921df 100644 --- a/test/open_telemetry/operation/error_type.json +++ b/test/open_telemetry/operation/error_type.json @@ -98,7 +98,7 @@ "$$type": "string" }, "error.type": { - "$$exists": false + "$$type": "string" } }, "nested": [ @@ -209,7 +209,7 @@ "$$type": "string" }, "error.type": { - "$$exists": false + "$$type": "string" } }, "nested": [ From b5c96832a649d9853bec5684321e2d03c01880ab Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Thu, 3 Sep 2026 08:34:10 -0500 Subject: [PATCH 08/10] PYTHON-6045 Test error.type equality on the operation span for a server error Confirms end_operation_span_failure derives error.type from the exception class name even when the underlying failure carries a server error code, rather than the command span's status code. --- test/asynchronous/test_otel.py | 14 ++++++++++++++ test/test_otel.py | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 857178ee4d..6294463c4a 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -574,6 +574,20 @@ async def test_failure_records_exception_and_status_code(self): self.assertEqual(span.attributes["error.type"], span.attributes["db.response.status_code"]) self.assertTrue(any(event.name == "exception" for event in span.events)) + async def test_operation_span_error_type_is_exception_class_name_for_server_error(self): + client = await self.async_rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with self.assertRaises(OperationFailure) as ctx: + await client[self.db.name].command("thisCommandDoesNotExist") + + (op_span,) = [ + s + for s in self.spans() + if "db.operation.name" in s.attributes and "db.command.name" not in s.attributes + ] + self.assertEqual(op_span.attributes["error.type"], op_span.attributes["exception.type"]) + self.assertEqual(op_span.attributes["error.type"], _qualified_name(type(ctx.exception))) + @async_client_context.require_failCommand_fail_point async def test_error_type_is_exception_class_name_for_connection_failure(self): # A closed connection produces no server reply, so error.type uses the class name. diff --git a/test/test_otel.py b/test/test_otel.py index 3bdc8d8eee..732146792d 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -593,6 +593,20 @@ def test_failure_records_exception_and_status_code(self): self.assertEqual(span.attributes["error.type"], span.attributes["db.response.status_code"]) self.assertTrue(any(event.name == "exception" for event in span.events)) + def test_operation_span_error_type_is_exception_class_name_for_server_error(self): + client = self.rs_or_single_client(tracing={"enabled": True}) + self.exporter.clear() + with self.assertRaises(OperationFailure) as ctx: + client[self.db.name].command("thisCommandDoesNotExist") + + (op_span,) = [ + s + for s in self.spans() + if "db.operation.name" in s.attributes and "db.command.name" not in s.attributes + ] + self.assertEqual(op_span.attributes["error.type"], op_span.attributes["exception.type"]) + self.assertEqual(op_span.attributes["error.type"], _qualified_name(type(ctx.exception))) + @client_context.require_failCommand_fail_point def test_error_type_is_exception_class_name_for_connection_failure(self): # A closed connection produces no server reply, so error.type uses the class name. From da72711a1a44f5ececea8394b004c7a593935ff2 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Tue, 15 Sep 2026 20:56:27 -0500 Subject: [PATCH 09/10] Strengthen the error.type prose tests Prose Test 5 now also asserts the operation span's error.type equals its exception.type. Prose Test 6 now drives failCommand on find with a non-retryable errorCode and asserts the operation span's error.type equals its exception.type and differs from the command span's db.response.status_code. --- test/asynchronous/test_otel.py | 43 ++++++++++++++++++++-------------- test/test_otel.py | 43 ++++++++++++++++++++-------------- 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/test/asynchronous/test_otel.py b/test/asynchronous/test_otel.py index 6294463c4a..d146cc2cc0 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -574,19 +574,28 @@ async def test_failure_records_exception_and_status_code(self): self.assertEqual(span.attributes["error.type"], span.attributes["db.response.status_code"]) self.assertTrue(any(event.name == "exception" for event in span.events)) + @async_client_context.require_failCommand_fail_point async def test_operation_span_error_type_is_exception_class_name_for_server_error(self): - client = await self.async_rs_or_single_client(tracing={"enabled": True}) - self.exporter.clear() - with self.assertRaises(OperationFailure) as ctx: - await client[self.db.name].command("thisCommandDoesNotExist") + # A non-retryable server error names the exception class on the operation + # span, not the server error code. + client = await self.async_rs_or_single_client(tracing={"enabled": True}, retryReads=False) + fail_command = { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "errorCode": 2}, + } + async with self.fail_point(fail_command): + self.exporter.clear() + with self.assertRaises(OperationFailure): + await client[self.db.name].test.find_one({}) - (op_span,) = [ - s - for s in self.spans() - if "db.operation.name" in s.attributes and "db.command.name" not in s.attributes - ] + finished = self.exporter.get_finished_spans() + (cmd_span,) = self.command_spans(finished, "find") + (op_span,) = self.operation_spans(finished, "find") self.assertEqual(op_span.attributes["error.type"], op_span.attributes["exception.type"]) - self.assertEqual(op_span.attributes["error.type"], _qualified_name(type(ctx.exception))) + self.assertNotEqual( + op_span.attributes["error.type"], cmd_span.attributes["db.response.status_code"] + ) @async_client_context.require_failCommand_fail_point async def test_error_type_is_exception_class_name_for_connection_failure(self): @@ -599,15 +608,15 @@ async def test_error_type_is_exception_class_name_for_connection_failure(self): } async with self.fail_point(fail_command): self.exporter.clear() - with self.assertRaises(ConnectionFailure) as ctx: + with self.assertRaises(ConnectionFailure): await client[self.db.name].test.find_one({}) - spans = [s for s in self.spans() if s.attributes.get("db.command.name") == "find"] - self.assertEqual(len(spans), 1) - attrs = spans[0].attributes - self.assertNotIn("db.response.status_code", attrs) - self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) - self.assertEqual(attrs["error.type"], attrs["exception.type"]) + finished = self.exporter.get_finished_spans() + (cmd_span,) = self.command_spans(finished, "find") + self.assertNotIn("db.response.status_code", cmd_span.attributes) + self.assertEqual(cmd_span.attributes["error.type"], cmd_span.attributes["exception.type"]) + (op_span,) = self.operation_spans(finished, "find") + self.assertEqual(op_span.attributes["error.type"], op_span.attributes["exception.type"]) @async_client_context.require_failCommand_blockConnection async def test_error_type_is_exception_class_name_for_network_timeout(self): diff --git a/test/test_otel.py b/test/test_otel.py index 732146792d..5b3089a384 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -593,19 +593,28 @@ def test_failure_records_exception_and_status_code(self): self.assertEqual(span.attributes["error.type"], span.attributes["db.response.status_code"]) self.assertTrue(any(event.name == "exception" for event in span.events)) + @client_context.require_failCommand_fail_point def test_operation_span_error_type_is_exception_class_name_for_server_error(self): - client = self.rs_or_single_client(tracing={"enabled": True}) - self.exporter.clear() - with self.assertRaises(OperationFailure) as ctx: - client[self.db.name].command("thisCommandDoesNotExist") + # A non-retryable server error names the exception class on the operation + # span, not the server error code. + client = self.rs_or_single_client(tracing={"enabled": True}, retryReads=False) + fail_command = { + "configureFailPoint": "failCommand", + "mode": {"times": 1}, + "data": {"failCommands": ["find"], "errorCode": 2}, + } + with self.fail_point(fail_command): + self.exporter.clear() + with self.assertRaises(OperationFailure): + client[self.db.name].test.find_one({}) - (op_span,) = [ - s - for s in self.spans() - if "db.operation.name" in s.attributes and "db.command.name" not in s.attributes - ] + finished = self.exporter.get_finished_spans() + (cmd_span,) = self.command_spans(finished, "find") + (op_span,) = self.operation_spans(finished, "find") self.assertEqual(op_span.attributes["error.type"], op_span.attributes["exception.type"]) - self.assertEqual(op_span.attributes["error.type"], _qualified_name(type(ctx.exception))) + self.assertNotEqual( + op_span.attributes["error.type"], cmd_span.attributes["db.response.status_code"] + ) @client_context.require_failCommand_fail_point def test_error_type_is_exception_class_name_for_connection_failure(self): @@ -618,15 +627,15 @@ def test_error_type_is_exception_class_name_for_connection_failure(self): } with self.fail_point(fail_command): self.exporter.clear() - with self.assertRaises(ConnectionFailure) as ctx: + with self.assertRaises(ConnectionFailure): client[self.db.name].test.find_one({}) - spans = [s for s in self.spans() if s.attributes.get("db.command.name") == "find"] - self.assertEqual(len(spans), 1) - attrs = spans[0].attributes - self.assertNotIn("db.response.status_code", attrs) - self.assertEqual(attrs["error.type"], _qualified_name(type(ctx.exception))) - self.assertEqual(attrs["error.type"], attrs["exception.type"]) + finished = self.exporter.get_finished_spans() + (cmd_span,) = self.command_spans(finished, "find") + self.assertNotIn("db.response.status_code", cmd_span.attributes) + self.assertEqual(cmd_span.attributes["error.type"], cmd_span.attributes["exception.type"]) + (op_span,) = self.operation_spans(finished, "find") + self.assertEqual(op_span.attributes["error.type"], op_span.attributes["exception.type"]) @client_context.require_failCommand_blockConnection def test_error_type_is_exception_class_name_for_network_timeout(self): From b77d22d7fd964b6332f1b104d60ac0a91e7ce675 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 16 Sep 2026 12:40:07 -0500 Subject: [PATCH 10/10] PYTHON-5947 Floor opentelemetry-sdk in min-deps otel test runs --- .evergreen/scripts/setup_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.evergreen/scripts/setup_tests.py b/.evergreen/scripts/setup_tests.py index 4f6833e289..9ea7cd3224 100644 --- a/.evergreen/scripts/setup_tests.py +++ b/.evergreen/scripts/setup_tests.py @@ -506,7 +506,9 @@ def handle_test_env() -> None: if test_name == "otel": # The SDK is test-only tooling (for the in-memory span exporter); the driver # itself must not depend on it, only on opentelemetry-api (the "opentelemetry" extra). - UV_ARGS.append("--with opentelemetry-sdk") + # The floor matches requirements/opentelemetry.txt: with --resolution=lowest-direct an + # unpinned sdk resolves to 1.0.0 and drags opentelemetry-api down to it. PYTHON-5947. + UV_ARGS.append('--with "opentelemetry-sdk>=1.20.0"') if test_name == "perf": data_dir = ROOT / "specifications/source/benchmarking/data"