diff --git a/.circleci/config.yml b/.circleci/config.yml index 85b9ddc6..cb31bc98 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -178,6 +178,12 @@ jobs: discovery.type: single-node xpack.security.enabled: "false" ES_JAVA_OPTS: "-Xms512m -Xmx512m" + - image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: YourStrong@Passw0rd + MSSQL_USER: sa + MSSQL_DATABASE: master working_directory: ~/repo steps: - checkout diff --git a/docker-compose.yml b/docker-compose.yml index 2fd473f0..54afd31c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,3 +111,13 @@ services: interval: 10s timeout: 5s retries: 5 + + mssql: + image: mcr.microsoft.com/azure-sql-edge + ports: + - 1433:1433 + environment: + ACCEPT_EULA: Y + MSSQL_DATABASE: master + MSSQL_USER: sa + MSSQL_SA_PASSWORD: YourStrong@Passw0rd diff --git a/src/instana/__init__.py b/src/instana/__init__.py index a8ecf07d..3df5cdb1 100644 --- a/src/instana/__init__.py +++ b/src/instana/__init__.py @@ -177,6 +177,7 @@ def boot_agent() -> None: pika, # noqa: F401 psycopg2, # noqa: F401 pymongo, # noqa: F401 + pymssql, # noqa: F401 pymysql, # noqa: F401 pyramid, # noqa: F401 redis, # noqa: F401 diff --git a/src/instana/instrumentation/pep0249.py b/src/instana/instrumentation/pep0249.py index 7ea8efaa..9fdddb17 100644 --- a/src/instana/instrumentation/pep0249.py +++ b/src/instana/instrumentation/pep0249.py @@ -1,7 +1,7 @@ # (c) Copyright IBM Corp. 2021 # (c) Copyright Instana Inc. 2018 -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union # This is a wrapper for PEP-0249: Python Database API Specification v2.0 import wrapt @@ -24,8 +24,8 @@ def __init__( self, cursor: Any, module_name: str, - connect_params: Optional[List[Union[str, Dict[str, Any]]]] = None, - cursor_params: Optional[Dict[str, Any]] = None, + connect_params: Optional[list[Union[str, dict[str, Any]]]] = None, + cursor_params: Optional[dict[str, Any]] = None, ) -> None: super(CursorWrapper, self).__init__(wrapped=cursor) self._module_name = module_name @@ -52,9 +52,15 @@ def _collect_kvs( self._connect_params[1][db_parameter_name], ) + host = next( + (p for p in ("host", "server") if p in self._connect_params[1]), + None, + ) + if host: + span.set_attribute("host", self._connect_params[1][host]) + span.set_attribute(SpanAttributes.DB_STATEMENT, sql_sanitizer(sql)) span.set_attribute(SpanAttributes.DB_USER, self._connect_params[1]["user"]) - span.set_attribute("host", self._connect_params[1]["host"]) span.set_attribute("port", self._connect_params[1]["port"]) except Exception as e: logger.debug(e) @@ -65,8 +71,8 @@ def __enter__(self) -> Self: def execute( self, sql: str, - params: Optional[Dict[str, Any]] = None, - ) -> Callable[[str, Dict[str, Any]], None]: + params: Optional[dict[str, Any]] = None, + ) -> Callable[[str, dict[str, Any]], None]: tracer, _, operation_name = get_tracer_tuple() # If not tracing or we're being called from sqlalchemy, just pass through @@ -90,8 +96,8 @@ def execute( def executemany( self, sql: str, - seq_of_parameters: List[Dict[str, Any]], - ) -> Callable[[str, List[Dict[str, Any]]], None]: + seq_of_parameters: list[dict[str, Any]], + ) -> Callable[[str, list[dict[str, Any]]], None]: tracer, _, operation_name = get_tracer_tuple() # If not tracing or we're being called from sqlalchemy, just pass through @@ -115,8 +121,8 @@ def executemany( def callproc( self, proc_name: str, - params: Dict[str, Any], - ) -> Callable[[str, Dict[str, Any]], None]: + params: dict[str, Any], + ) -> Callable[[str, dict[str, Any]], None]: tracer, _, operation_name = get_tracer_tuple() # If not tracing or we're being called from sqlalchemy, just pass through @@ -150,7 +156,7 @@ def __init__( self, connection: "ConnectionWrapper", module_name: str, - connect_params: List[Union[str, Dict[str, Any]]], + connect_params: list[Union[str, dict[str, Any]]], ) -> None: super(ConnectionWrapper, self).__init__(wrapped=connection) self._module_name = module_name @@ -161,8 +167,8 @@ def __enter__(self) -> Self: def cursor( self, - *args: Tuple[int, str, Dict[str, Any]], - **kwargs: Dict[str, Any], + *args: tuple[int, str, dict[str, Any]], + **kwargs: dict[str, Any], ) -> CursorWrapper: return CursorWrapper( cursor=self.__wrapped__.cursor(*args, **kwargs), @@ -193,8 +199,8 @@ def __init__( def __call__( self, - *args: Tuple[int, str, Dict[str, Any]], - **kwargs: Dict[str, Any], + *args: tuple[int, str, dict[str, Any]], + **kwargs: dict[str, Any], ) -> ConnectionWrapper: connect_params = (args, kwargs) if args or kwargs else None return self._wrapper_ctor( diff --git a/src/instana/instrumentation/pymssql.py b/src/instana/instrumentation/pymssql.py new file mode 100644 index 00000000..cc93067d --- /dev/null +++ b/src/instana/instrumentation/pymssql.py @@ -0,0 +1,17 @@ +# (c) Copyright IBM Corp. 2026 + +from instana.log import logger +from instana.instrumentation.pep0249 import ConnectionFactory + +try: + import pymssql + + cf = ConnectionFactory(connect_func=pymssql.connect, module_name="mssql") + + setattr(pymssql, "connect", cf) + if hasattr(pymssql, "Connect"): + setattr(pymssql, "Connect", cf) + + logger.debug("Instrumenting pymssql") +except ImportError: + pass diff --git a/src/instana/span/kind.py b/src/instana/span/kind.py index f7a074c3..35025bb8 100644 --- a/src/instana/span/kind.py +++ b/src/instana/span/kind.py @@ -51,6 +51,7 @@ "httpx", "log", "memcache", + "mssql", "mongo", "mysql", "postgres", diff --git a/src/instana/span/registered_span.py b/src/instana/span/registered_span.py index 339cf6e1..8234169a 100644 --- a/src/instana/span/registered_span.py +++ b/src/instana/span/registered_span.py @@ -147,6 +147,9 @@ def _populate_exit_span_data(self, span: "InstanaSpan") -> None: elif span.name == "mysql": self._collect_mysql_attributes(span) + elif span.name == "mssql": + self._collect_mssql_attributes(span) + elif span.name == "postgres": self._collect_postgres_attributes(span) @@ -366,6 +369,16 @@ def _collect_mysql_attributes(self, span: "InstanaSpan") -> None: ) self.data["mysql"]["error"] = span.attributes.pop("mysql.error", None) + def _collect_mssql_attributes(self, span: "InstanaSpan") -> None: + self.data["mssql"]["host"] = span.attributes.pop("host", None) + self.data["mssql"]["port"] = span.attributes.pop("port", None) + self.data["mssql"]["db"] = span.attributes.pop(SpanAttributes.DB_NAME, None) + self.data["mssql"]["user"] = span.attributes.pop(SpanAttributes.DB_USER, None) + self.data["mssql"]["stmt"] = span.attributes.pop( + SpanAttributes.DB_STATEMENT, None + ) + self.data["mssql"]["error"] = span.attributes.pop("mssql.error", None) + def _collect_postgres_attributes(self, span: "InstanaSpan") -> None: self.data["pg"]["host"] = span.attributes.pop("host", None) self.data["pg"]["port"] = span.attributes.pop("port", None) diff --git a/src/instana/span/span.py b/src/instana/span/span.py index 49dbe859..8bee0c0c 100644 --- a/src/instana/span/span.py +++ b/src/instana/span/span.py @@ -170,6 +170,8 @@ def record_exception( self.set_attribute("lambda.error", message) elif self.name.startswith("kafka"): self.set_attribute("kafka.error", message) + elif self.name == "mssql": + self.set_attribute("mssql.error", message) else: _attributes = {"message": message} if attributes: diff --git a/tests/clients/test_pymssql.py b/tests/clients/test_pymssql.py new file mode 100644 index 00000000..02f81003 --- /dev/null +++ b/tests/clients/test_pymssql.py @@ -0,0 +1,242 @@ +# (c) Copyright IBM Corp. 2026 + +from typing import Generator + +import pytest + +from instana.singletons import agent, get_tracer +from tests.helpers import testenv + + +class TestPyMSSQL: + @pytest.fixture(autouse=True) + def _resource(self) -> Generator[None, None, None]: + import pymssql + + try: + self.db = pymssql.connect( + server=testenv["mssql_host"], + port=testenv["mssql_port"], + user=testenv["mssql_user"], + password=testenv["mssql_pw"], + database=testenv["mssql_db"], + ) + except Exception: + pytest.skip("SQL Server not available") + + setup_cursor = self.db.cursor() + setup_cursor.execute("IF OBJECT_ID('users', 'U') IS NOT NULL DROP TABLE users") + setup_cursor.execute( + "CREATE TABLE users (id INT, name NVARCHAR(50), email NVARCHAR(50))" + ) + setup_cursor.execute( + "INSERT INTO users (id, name, email) VALUES (1, 'kermit', 'kermit@muppets.com')" + ) + self.db.commit() + + self.cursor = self.db.cursor() + self.tracer = get_tracer() + self.recorder = self.tracer.span_processor + self.recorder.clear_spans() + self.tracer.cur_ctx = None + yield + try: + cleanup_cursor = self.db.cursor() + cleanup_cursor.execute( + "IF OBJECT_ID('users', 'U') IS NOT NULL DROP TABLE users" + ) + self.db.commit() + self.cursor.close() + self.db.close() + except Exception: + pass + agent.options.allow_exit_as_root = False + + # ------------------------------------------------------------------ US1 -- + + def test_vanilla_query(self) -> None: + """No tracer context → zero spans emitted.""" + self.cursor.execute("SELECT * FROM users") + rows = self.cursor.fetchall() + assert len(rows) == 1 + + spans = self.recorder.queued_spans() + assert len(spans) == 0 + + def test_basic_query(self) -> None: + """SELECT inside tracer context → one mssql child span with all attributes.""" + with self.tracer.start_as_current_span("test"): + self.cursor.execute("SELECT * FROM users") + rows = self.cursor.fetchall() + + assert len(rows) == 1 + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + + db_span, test_span = spans + + assert test_span.data["sdk"]["name"] == "test" + assert test_span.t == db_span.t + assert db_span.p == test_span.s + + assert not db_span.ec + assert db_span.n == "mssql" + assert db_span.data["mssql"]["db"] == testenv["mssql_db"] + assert db_span.data["mssql"]["user"] == testenv["mssql_user"] + assert db_span.data["mssql"]["stmt"] == "SELECT * FROM users" + assert db_span.data["mssql"]["host"] == testenv["mssql_host"] + assert db_span.data["mssql"]["port"] == testenv["mssql_port"] + + def test_basic_query_as_root_exit_span(self) -> None: + """Root exit span (no parent) is captured when allow_exit_as_root is True.""" + agent.options.allow_exit_as_root = True + self.cursor.execute("SELECT * FROM users") + rows = self.cursor.fetchall() + + assert len(rows) == 1 + + spans = self.recorder.queued_spans() + assert len(spans) == 1 + + db_span = spans[0] + + assert not db_span.ec + assert db_span.n == "mssql" + assert db_span.data["mssql"]["db"] == testenv["mssql_db"] + assert db_span.data["mssql"]["user"] == testenv["mssql_user"] + assert db_span.data["mssql"]["stmt"] == "SELECT * FROM users" + assert db_span.data["mssql"]["host"] == testenv["mssql_host"] + assert db_span.data["mssql"]["port"] == testenv["mssql_port"] + + @pytest.mark.parametrize( + "sql,expected_stmt", + [ + ( + "SELECT * FROM users WHERE id = 1", + "SELECT * FROM users WHERE id = ?", + ), + ( + "INSERT INTO users (id, name, email) VALUES (2, 'beaker', 'beaker@muppets.com')", + "INSERT INTO users (id, name, email) VALUES (?, ?, ?)", + ), + ( + "UPDATE users SET name = 'gonzo' WHERE id = 1", + "UPDATE users SET name = ? WHERE id = ?", + ), + ], + ) + def test_span_attributes(self, sql: str, expected_stmt: str) -> None: + """All five non-error span attributes are populated for each DML statement.""" + with self.tracer.start_as_current_span("test"): + try: + self.cursor.execute(sql) + self.db.commit() + except Exception: + self.db.rollback() + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + db_span = spans[0] + + assert db_span.n == "mssql" + assert db_span.data["mssql"]["db"] == testenv["mssql_db"] + assert db_span.data["mssql"]["user"] == testenv["mssql_user"] + assert db_span.data["mssql"]["stmt"] == expected_stmt + assert db_span.data["mssql"]["host"] == testenv["mssql_host"] + assert db_span.data["mssql"]["port"] == testenv["mssql_port"] + assert not db_span.ec + + def test_connect_cursor_ctx_mgr(self) -> None: + """Cursor used as a context manager produces the same span output.""" + with self.tracer.start_as_current_span("test"), self.cursor: + self.cursor.execute("SELECT * FROM users") + rows = self.cursor.fetchall() + + assert len(rows) == 1 + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + db_span = spans[0] + + assert db_span.n == "mssql" + assert db_span.data["mssql"]["stmt"] == "SELECT * FROM users" + assert not db_span.ec + + # ------------------------------------------------------------------ US2 -- + + @pytest.mark.parametrize( + "bad_sql", + [ + "SELECT * FROM nonexistent_table_xyz", + "THIS IS NOT VALID SQL AT ALL", + ], + ) + def test_error_capture(self, bad_sql: str) -> None: + """Failed queries record ec=1 and populate the error attribute.""" + with self.tracer.start_as_current_span("test"), pytest.raises(Exception): + self.cursor.execute(bad_sql) + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + db_span = spans[0] + + assert db_span.n == "mssql" + assert db_span.ec == 2 + assert db_span.data["mssql"]["error"] is not None + assert len(db_span.data["mssql"]["error"]) > 0 + + def test_no_error_on_success(self) -> None: + """Successful queries leave ec falsy and error attribute as None.""" + with self.tracer.start_as_current_span("test"): + self.cursor.execute("SELECT * FROM users") + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + db_span = spans[0] + + assert db_span.n == "mssql" + assert not db_span.ec + assert db_span.data["mssql"]["error"] is None + + # ------------------------------------------------------------------ US3 -- + + @pytest.mark.parametrize( + "batch_rows", + [ + [(2, "beaker", "beaker@muppets.com"), (3, "fozzie", "fozzie@muppets.com")], + [ + (2, "beaker", "b@m.com"), + (3, "fozzie", "f@m.com"), + (4, "gonzo", "g@m.com"), + (5, "piggy", "p@m.com"), + (6, "animal", "a@m.com"), + ], + ], + ) + def test_executemany(self, batch_rows: list) -> None: + """executemany produces exactly one mssql span regardless of batch size.""" + sql = "INSERT INTO users (id, name, email) VALUES (%d, %s, %s)" + with self.tracer.start_as_current_span("test"): + self.cursor.executemany(sql, batch_rows) + self.db.commit() + + spans = self.recorder.queued_spans() + assert len(spans) == 2 + + db_span = spans[0] + assert db_span.n == "mssql" + assert db_span.data["mssql"]["stmt"] is not None + assert not db_span.ec + + # --------------------------------------------------------------- Polish -- + + def test_sqlalchemy_bypass(self) -> None: + """When the active span is 'sqlalchemy', no mssql span is created.""" + with self.tracer.start_as_current_span("sqlalchemy"): + self.cursor.execute("SELECT * FROM users") + + spans = self.recorder.queued_spans() + # Only the sqlalchemy span; no mssql child + assert len(spans) == 1 + assert spans[0].n == "sqlalchemy" diff --git a/tests/helpers.py b/tests/helpers.py index d65ede71..cbb23181 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -58,6 +58,15 @@ testenv["mongodb_user"] = os.environ.get("MONGO_USER", None) testenv["mongodb_pw"] = os.environ.get("MONGO_PW", None) +""" +Microsoft SQL Server Environment +""" +testenv["mssql_host"] = os.environ.get("MSSQL_HOST", "127.0.0.1") +testenv["mssql_port"] = os.environ.get("MSSQL_PORT", "1433") +testenv["mssql_db"] = os.environ.get("MSSQL_DATABASE", "master") +testenv["mssql_user"] = os.environ.get("MSSQL_USER", "sa") +testenv["mssql_pw"] = os.environ.get("MSSQL_SA_PASSWORD", "YourStrong@Passw0rd") + """ RabbitMQ Environment """ diff --git a/tests/requirements.txt b/tests/requirements.txt index 242085c9..73a19092 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -18,6 +18,7 @@ lxml>=4.9.2 mock>=4.0.3 moto>=4.1.2 mysqlclient>=2.0.3 +pymssql>=2.2.0 PyMySQL[rsa]>=1.0.2 psycopg2-binary>=2.8.6 pika>=1.2.0