Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions src/instana/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/instana/instrumentation/pep0249.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,15 @@ def _collect_kvs(
self._connect_params[1][db_parameter_name],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're changing the file, should we refactor Dict, List, Tuple to dict, list, tuple? We can do this for any file we touch.

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)
Expand Down
17 changes: 17 additions & 0 deletions src/instana/instrumentation/pymssql.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/instana/span/kind.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"httpx",
"log",
"memcache",
"mssql",
"mongo",
"mysql",
"postgres",
Expand Down
13 changes: 13 additions & 0 deletions src/instana/span/registered_span.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/instana/span/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[1])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure that it should be set to message[1]?

else:
_attributes = {"message": message}
if attributes:
Expand Down
242 changes: 242 additions & 0 deletions tests/clients/test_pymssql.py
Original file line number Diff line number Diff line change
@@ -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"
9 changes: 9 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down
1 change: 1 addition & 0 deletions tests/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading