From 45533bef9f5b095b43fae2e380e57c90f9964d35 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 19 Aug 2026 15:16:32 -0500 Subject: [PATCH 1/2] PYTHON-5947 Add error.type OpenTelemetry command span attribute Implements DRIVERS-3617: 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 f1cf253b16..6f4938e4b5 100644 --- a/pymongo/_otel.py +++ b/pymongo/_otel.py @@ -446,12 +446,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__ @@ -462,6 +464,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( @@ -473,10 +476,16 @@ def end_command_span_failure( if span is None: return 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"))) span.end() 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 5583231d80e83fd8ff22b21e2221747e35cc4c32 Mon Sep 17 00:00:00 2001 From: Steven Silvester Date: Wed, 19 Aug 2026 15:49:04 -0500 Subject: [PATCH 2/2] 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 f978909d78..9bc51f317e 100644 --- a/test/asynchronous/test_otel.py +++ b/test/asynchronous/test_otel.py @@ -32,7 +32,9 @@ from pymongo.errors import ( ClientBulkWriteException, ConfigurationError, + ConnectionFailure, InvalidOperation, + NetworkTimeout, OperationFailure, ServerSelectionTimeoutError, ) @@ -66,6 +68,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.""" @@ -498,8 +505,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 85d0e1a093..c477ba6288 100644 --- a/test/test_otel.py +++ b/test/test_otel.py @@ -32,7 +32,9 @@ from pymongo.errors import ( ClientBulkWriteException, ConfigurationError, + ConnectionFailure, InvalidOperation, + NetworkTimeout, OperationFailure, ServerSelectionTimeoutError, ) @@ -66,6 +68,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.""" @@ -498,8 +505,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()