From 6723dc4ee461fd66746ec1fe24426cf286cc2508 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 19 Aug 2026 15:16:32 -0500 Subject: [PATCH 1/4] 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 604a75857a..9fb201dd30 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -409,12 +409,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__ @@ -425,6 +427,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( @@ -437,10 +440,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 f6c57411f1a7be29154ab9434d03434cbda973f0 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 19 Aug 2026 15:49:04 -0500 Subject: [PATCH 2/4] 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 130a7ee1da..93d35c6781 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -33,7 +33,9 @@ from pymongo.errors import ( ClientBulkWriteException, ConfigurationError, + ConnectionFailure, InvalidOperation, + NetworkTimeout, OperationFailure, ServerSelectionTimeoutError, ) @@ -67,6 +69,11 @@ def _tracing_opts() -> _otel.TracingOptions: return {"enabled": True, "query_text_max_length": 0} +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.""" @@ -496,8 +503,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 f1d06a4e68..eeb1ea2c88 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -33,7 +33,9 @@ from pymongo.errors import ( ClientBulkWriteException, ConfigurationError, + ConnectionFailure, InvalidOperation, + NetworkTimeout, OperationFailure, ServerSelectionTimeoutError, ) @@ -67,6 +69,11 @@ def _tracing_opts() -> _otel.TracingOptions: return {"enabled": True, "query_text_max_length": 0} +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.""" @@ -496,8 +503,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 1a0330d2a1157b1efb7162eaec8a5c18773052ff Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Fri, 21 Aug 2026 10:51:42 -0500 Subject: [PATCH 3/4] 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 9fb201dd30..1479d900b6 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -414,9 +414,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__ @@ -443,12 +443,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 93d35c6781..bcf9f89d85 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -509,8 +509,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", @@ -527,14 +527,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 eeb1ea2c88..8474a827de 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -509,8 +509,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", @@ -527,14 +527,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 ec5bad152f68eb93162e097f9a2c41dbc63004e9 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 2 Sep 2026 06:47:47 -0500 Subject: [PATCH 4/4] 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 bcf9f89d85..fc8141d392 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -509,8 +509,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", @@ -527,12 +526,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 8474a827de..db876acc8f 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -509,8 +509,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", @@ -527,12 +526,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 )