From fec12baf3fb491dd28e171c933e49a5aa1e298ed Mon Sep 17 00:00:00 2001 From: Joel Marcotte <91903666+joelmarcotte@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:39:16 -0400 Subject: [PATCH] Add SQL Server diagnostics (#23621) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add SQL Server diagnostics * Add SQL Server diagnostics changelog * Address Codex review on SQL Server diagnostics - skip server-level VIEW SERVER STATE probe on Azure SQL Database and validate via sys.dm_exec_sessions (VIEW DATABASE STATE) - probe msdb.dbo.syssessions when agent_jobs is enabled and not on RDS Co-Authored-By: Claude Opus 4.7 (1M context) * Explicitly check VIEW DATABASE STATE on Azure SQL Database On Azure SQL Database, `sys.dm_exec_sessions` always returns the caller's own session row, so probing it cannot detect a missing `VIEW DATABASE STATE`. Validate the permission with `HAS_PERMS_BY_NAME(DB_NAME(), 'DATABASE', 'VIEW DATABASE STATE')` before the session probe. Co-Authored-By: Claude Opus 4.7 (1M context) * Eliminate reference cycle in SQL Server diagnose Mirrors the postgres fix in #23647: register a module-level ``run_diagnostics`` via ``functools.partial`` so a fresh ``SqlserverDiagnose`` worker is built per invocation and disposed of when ``_run()`` returns. The previous shape kept a ``SqlserverDiagnose`` instance alive whose bound ``_run`` was held by ``Diagnosis``, forming a check → diagnosis → bound method → diagnose worker → check cycle. Co-Authored-By: Claude Opus 4.7 (1M context) * Use NULL securable in HAS_PERMS_BY_NAME for VIEW DATABASE STATE Azure SQL Database names can contain a dot. Passing ``DB_NAME()`` as the securable made ``HAS_PERMS_BY_NAME`` parse the name as a multipart identifier, returning 0 even when ``VIEW DATABASE STATE`` was granted and producing a false-negative diagnostic. Using NULL targets the current database unambiguously. Co-Authored-By: Claude Opus 4.7 (1M context) * Skip baseline diagnostic probes under only_custom_queries When only_custom_queries is set the check skips load_basic_metrics and database_metrics, so the Datadog login does not need access to sys.dm_os_performance_counters or the database_metrics-only msdb tables. Guard those probes (and VIEW SERVER STATE / VIEW ANY DEFINITION) so they only run when the corresponding metric path is active or DBM is enabled. Co-Authored-By: Claude Opus 4.7 (1M context) * Address SQL Server diagnose review feedback * Improve SQL Server diagnose remediation * Add SQL Server diagnostics for Azure perf state, ODBC driver, and per-db access Adds three high-signal pre-flight probes inspired by recurring SDBM escalations: - VIEW DATABASE PERFORMANCE STATE on Azure SQL Database / Managed Instance, probed with sys.dm_io_virtual_file_stats (SDBM-1707, SDBM-1504). - ODBC driver installed: compares the configured driver to pyodbc.drivers() before connecting (SDBM-1939). - Per-database access under autodiscovery: enumerates online user databases, applies the autodiscovery include/exclude filters, and probes each with USE/SELECT TOP 1 1 (SDBM-2293, SDBM-2401). Co-Authored-By: Claude Opus 4.7 (1M context) * Add per-database VIEW DATABASE STATE diagnostic The existing per-database access probe only verified that the Datadog login could USE each autodiscovered database. A login can pass that probe and still produce no per-database DBM data because the per-db DMV reads (sys.dm_exec_sessions filtered by database_id, sys.dm_db_index_usage_stats, sys.dm_db_file_space_usage, ...) require VIEW DATABASE STATE on the database — the exact permission the existing remediation already told users to grant but never checked. Extends the per-database loop to call HAS_PERMS_BY_NAME for VIEW DATABASE STATE on each accessible database and emits a new missing-per-database-view-state diagnosis with failures listed by database name. Skipped on Azure SQL Database (per_database_access is also skipped there) and when no database was accessible (the access probe already reports that case). Co-Authored-By: Claude Opus 4.7 (1M context) * format --------- Co-authored-by: Claude Opus 4.7 (1M context) --- sqlserver/changelog.d/23621.added | 1 + .../datadog_checks/sqlserver/diagnose.py | 703 +++++++++++++++++ .../datadog_checks/sqlserver/sqlserver.py | 4 + sqlserver/tests/test_diagnose.py | 732 ++++++++++++++++++ 4 files changed, 1440 insertions(+) create mode 100644 sqlserver/changelog.d/23621.added create mode 100644 sqlserver/datadog_checks/sqlserver/diagnose.py create mode 100644 sqlserver/tests/test_diagnose.py diff --git a/sqlserver/changelog.d/23621.added b/sqlserver/changelog.d/23621.added new file mode 100644 index 0000000000000..7e178cb5f4637 --- /dev/null +++ b/sqlserver/changelog.d/23621.added @@ -0,0 +1 @@ +Add explicit diagnostics for SQL Server setup validation. \ No newline at end of file diff --git a/sqlserver/datadog_checks/sqlserver/diagnose.py b/sqlserver/datadog_checks/sqlserver/diagnose.py new file mode 100644 index 0000000000000..5784081e33c69 --- /dev/null +++ b/sqlserver/datadog_checks/sqlserver/diagnose.py @@ -0,0 +1,703 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +""" +Explicit pre-flight diagnostics for the SQL Server integration. + +Registered with ``self.diagnosis.register(...)`` in ``SQLServer.__init__`` and +run on-demand when the Agent invokes ``get_diagnoses()``. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from datadog_checks.base import ConfigurationError, is_affirmative +from datadog_checks.sqlserver.connection_errors import SQLConnectionError +from datadog_checks.sqlserver.const import STATIC_INFO_ENGINE_EDITION +from datadog_checks.sqlserver.utils import is_azure_database, is_azure_sql_database + +CATEGORY_SQLSERVER = "sqlserver" +KEY_PREFIX = "sqlserver-diagnose-" +MIN_SUPPORTED_MAJOR_VERSION = 11 +SQLSERVER_2014_MAJOR_VERSION = 12 +PER_DATABASE_PROBE_LIMIT = 50 +SYSTEM_DATABASES = ("master", "tempdb", "model", "msdb") + +SQLSERVER_SETUP_DOCS_URL = "https://docs.datadoghq.com/integrations/sql-server/?tab=host#setup" +SQLSERVER_TROUBLESHOOTING_DOCS_URL = "https://docs.datadoghq.com/database_monitoring/setup_sql_server/troubleshooting/" +SQLSERVER_DBM_GRANTS_DOCS_URL = ( + "https://docs.datadoghq.com/database_monitoring/setup_sql_server/selfhosted/" + "?tab=sqlserver2014#grant-the-agent-access" +) + + +class SQLServerConfigurationError(Enum): + """SQL Server diagnostic error codes.""" + + connection_failure = "connection-failure" + sqlserver_version_unsupported = "sqlserver-version-unsupported" + performance_counters_not_readable = "performance-counters-not-readable" + missing_view_server_state = "missing-view-server-state" + missing_view_database_performance_state = "missing-view-database-performance-state" + missing_connect_any_database = "missing-connect-any-database" + missing_view_any_definition = "missing-view-any-definition" + missing_msdb_select = "missing-msdb-select" + odbc_driver_not_installed = "odbc-driver-not-installed" + per_database_access = "per-database-access" + missing_per_database_view_state = "missing-per-database-view-state" + + +DIAGNOSTIC_METADATA = { + SQLServerConfigurationError.connection_failure: { + "description": "Verifies that the Agent can connect to the configured SQL Server database.", + "remediation": "Review the SQL Server host, port, driver, authentication, and TLS settings.", + "docs_url": (SQLSERVER_SETUP_DOCS_URL, SQLSERVER_TROUBLESHOOTING_DOCS_URL), + }, + SQLServerConfigurationError.sqlserver_version_unsupported: { + "description": "Verifies that SQL Server is a supported version for the integration.", + "remediation": "Use SQL Server 2012 or newer.", + "docs_url": SQLSERVER_SETUP_DOCS_URL, + }, + SQLServerConfigurationError.performance_counters_not_readable: { + "description": "Verifies read access to sys.dm_os_performance_counters.", + "remediation": "Grant SELECT on sys.dm_os_performance_counters to the Datadog login.", + "docs_url": SQLSERVER_SETUP_DOCS_URL, + }, + SQLServerConfigurationError.missing_view_server_state: { + "description": ( + "Verifies VIEW SERVER STATE (or VIEW DATABASE STATE on Azure SQL Database) for server state, " + "query metrics, and query activity collection." + ), + "remediation": "Grant VIEW SERVER STATE (or VIEW DATABASE STATE on Azure SQL Database) to the Datadog login.", + "docs_url": SQLSERVER_SETUP_DOCS_URL, + }, + SQLServerConfigurationError.missing_view_database_performance_state: { + "description": ( + "Verifies VIEW DATABASE PERFORMANCE STATE on Azure SQL Database / Managed Instance, required by " + "sys.dm_io_virtual_file_stats and the DBM query activity DMVs." + ), + "remediation": ( + "Grant VIEW DATABASE PERFORMANCE STATE to the Datadog login on the current database " + "(Azure SQL Database / Managed Instance only)." + ), + "docs_url": SQLSERVER_DBM_GRANTS_DOCS_URL, + }, + SQLServerConfigurationError.missing_connect_any_database: { + "description": "Verifies CONNECT ANY DATABASE when the check can fan out across databases.", + "remediation": "Grant CONNECT ANY DATABASE to the Datadog login.", + "docs_url": SQLSERVER_DBM_GRANTS_DOCS_URL, + }, + SQLServerConfigurationError.missing_view_any_definition: { + "description": "Verifies VIEW ANY DEFINITION for DBM metadata and definition-dependent metrics.", + "remediation": "Grant VIEW ANY DEFINITION to the Datadog login.", + "docs_url": SQLSERVER_DBM_GRANTS_DOCS_URL, + }, + SQLServerConfigurationError.missing_msdb_select: { + "description": "Verifies read access to enabled SQL Server features backed by msdb tables.", + "remediation": "Create the Datadog user in msdb and grant SELECT on the required msdb tables.", + "docs_url": SQLSERVER_SETUP_DOCS_URL, + }, + SQLServerConfigurationError.odbc_driver_not_installed: { + "description": "Verifies that the configured ODBC driver is installed on the Agent host.", + "remediation": ( + "Install the configured ODBC driver, or update the 'driver' setting to match an installed driver." + ), + "docs_url": SQLSERVER_SETUP_DOCS_URL, + }, + SQLServerConfigurationError.per_database_access: { + "description": ( + "Verifies the Datadog login can connect to each database that database autodiscovery would monitor." + ), + "remediation": ( + "Grant the Datadog login access to the listed databases (CREATE USER ... FOR LOGIN; " + "GRANT VIEW DATABASE STATE) and confirm they are online." + ), + "docs_url": SQLSERVER_DBM_GRANTS_DOCS_URL, + }, + SQLServerConfigurationError.missing_per_database_view_state: { + "description": ( + "Verifies VIEW DATABASE STATE on each autodiscovered database, required by the per-database " + "DBM DMV reads (sys.dm_exec_sessions, sys.dm_db_index_usage_stats, sys.dm_db_file_space_usage, ...)." + ), + "remediation": ("Grant VIEW DATABASE STATE to the Datadog user on the listed databases."), + "docs_url": SQLSERVER_DBM_GRANTS_DOCS_URL, + }, +} + + +def build_remediation(code: SQLServerConfigurationError) -> str: + """Return remediation text with the relevant Datadog docs URL.""" + metadata = DIAGNOSTIC_METADATA[code] + docs_url = metadata["docs_url"] + docs_urls = docs_url if isinstance(docs_url, tuple) else (docs_url,) + return "{} See {}.".format(metadata["remediation"], " and ".join(docs_urls)) + + +def run_diagnostics(check: Any) -> None: + """Entry point for ``Diagnosis.register()``; creates a short-lived worker per invocation.""" + SqlserverDiagnose(check)._run() + + +class SqlserverDiagnose: + """Explicit pre-flight diagnostics for `datadog-agent diagnose`.""" + + def __init__(self, check: Any) -> None: + self._check = check + self._failed: set[str] = set() + self._major_version: int | None = None + self._engine_edition: int | None = None + self._is_rds: bool | None = None + + def _run(self) -> None: + """Open one probe connection and run enabled diagnostics.""" + self._failed = set() + self._major_version = None + self._engine_edition = None + self._is_rds = None + + self._diagnose_odbc_driver_installed() + + try: + with self._check.connection.open_managed_default_connection(KEY_PREFIX): + with self._check.connection.get_managed_cursor(KEY_PREFIX) as cursor: + self._diagnose_connection() + self._diagnose_version(cursor) + if self._needs_performance_counters(): + self._diagnose_performance_counters(cursor) + if self._needs_view_server_state(): + self._diagnose_view_server_state(cursor) + if self._needs_view_database_performance_state(): + self._diagnose_view_database_performance_state(cursor) + if self._needs_connect_any_database(): + self._diagnose_connect_any_database(cursor) + if self._needs_view_any_definition(): + self._diagnose_view_any_definition(cursor) + if self._needs_msdb_select(): + self._detect_rds(cursor) + self._diagnose_msdb_select(cursor) + if self._needs_per_database_access(): + self._diagnose_per_database_access(cursor) + except (ConfigurationError, SQLConnectionError) as e: + code = SQLServerConfigurationError.connection_failure + self._fail( + code, + diagnosis="Failed to connect to {} as {}: {}".format(self._database_desc(), self._username_desc(), e), + rawerror=str(e), + ) + + def _diagnose_connection(self) -> None: + code = SQLServerConfigurationError.connection_failure + self._check.diagnosis.success( + name=code.value, + diagnosis="Connected to {} as {}.".format(self._database_desc(), self._username_desc()), + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_odbc_driver_installed(self) -> None: + if self._resolved_connector() != "odbc": + return + configured = self._check.instance.get("driver") + if not configured: + return + installed = _list_pyodbc_drivers() + if installed is None: + return + if _normalize_driver_name(configured) in installed: + return + code = SQLServerConfigurationError.odbc_driver_not_installed + self._fail( + code, + diagnosis="Configured ODBC driver {!r} is not in the list of installed drivers: {}".format( + configured, installed + ), + ) + + def _diagnose_version(self, cursor: Any) -> None: + code = SQLServerConfigurationError.sqlserver_version_unsupported + try: + row = _fetchone( + cursor, + "SELECT CAST(SERVERPROPERTY('ProductMajorVersion') AS INT), " + "CAST(SERVERPROPERTY('EngineEdition') AS INT)", + ) + self._major_version = _to_int(row[0]) if row else None + self._engine_edition = _to_int(row[1]) if row and len(row) > 1 else None + except Exception as e: + self._fail(code, diagnosis="Unable to determine SQL Server version: {}".format(e), rawerror=str(e)) + return + + if self._major_version is None: + self._fail(code, diagnosis="Unable to determine SQL Server major version.") + return + + if self._major_version < MIN_SUPPORTED_MAJOR_VERSION: + self._fail( + code, + diagnosis="SQL Server major version {} is below the minimum supported version (11).".format( + self._major_version + ), + ) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="SQL Server major version {} is supported.".format(self._major_version), + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_performance_counters(self, cursor: Any) -> None: + code = SQLServerConfigurationError.performance_counters_not_readable + try: + _execute_read_probe(cursor, "SELECT TOP 1 object_name FROM sys.dm_os_performance_counters") + except Exception as e: + self._fail( + code, + diagnosis="Unable to read sys.dm_os_performance_counters: {}".format(e), + rawerror=str(e), + ) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="sys.dm_os_performance_counters is readable.", + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_view_database_performance_state(self, cursor: Any) -> None: + code = SQLServerConfigurationError.missing_view_database_performance_state + try: + if not _has_database_permission(cursor, "VIEW DATABASE PERFORMANCE STATE"): + self._fail( + code, + diagnosis=( + "The Datadog login does not have VIEW DATABASE PERFORMANCE STATE on the current database." + ), + ) + return + _execute_read_probe( + cursor, + "SELECT TOP 1 database_id FROM sys.dm_io_virtual_file_stats(DB_ID(), NULL)", + ) + except Exception as e: + self._fail( + code, + diagnosis=( + "Unable to validate VIEW DATABASE PERFORMANCE STATE with sys.dm_io_virtual_file_stats: {}".format(e) + ), + rawerror=str(e), + ) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="VIEW DATABASE PERFORMANCE STATE is granted and sys.dm_io_virtual_file_stats is readable.", + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_view_server_state(self, cursor: Any) -> None: + code = SQLServerConfigurationError.missing_view_server_state + azure = is_azure_sql_database(self._current_engine_edition()) + permission_label = "VIEW DATABASE STATE" if azure else "VIEW SERVER STATE" + try: + if azure: + if not _has_database_permission(cursor, "VIEW DATABASE STATE"): + self._fail( + code, + diagnosis="The Datadog login does not have VIEW DATABASE STATE on the current database.", + ) + return + elif not _has_server_permission(cursor, "VIEW SERVER STATE"): + self._fail(code, diagnosis="The Datadog login does not have VIEW SERVER STATE.") + return + _execute_read_probe(cursor, "SELECT TOP 1 session_id FROM sys.dm_exec_sessions") + except Exception as e: + self._fail( + code, + diagnosis="Unable to validate {} with sys.dm_exec_sessions: {}".format(permission_label, e), + rawerror=str(e), + ) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="{} is granted and sys.dm_exec_sessions is readable.".format(permission_label), + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_connect_any_database(self, cursor: Any) -> None: + code = SQLServerConfigurationError.missing_connect_any_database + if self._major_version is not None and self._major_version < SQLSERVER_2014_MAJOR_VERSION: + self._check.diagnosis.warning( + name=code.value, + diagnosis=( + "CONNECT ANY DATABASE is unavailable before SQL Server 2014; ensure the Datadog user exists " + "in every monitored database." + ), + category=CATEGORY_SQLSERVER, + description=DIAGNOSTIC_METADATA[code]["description"], + remediation=build_remediation(code), + ) + return + + try: + if not _has_server_permission(cursor, "CONNECT ANY DATABASE"): + self._fail(code, diagnosis="The Datadog login does not have CONNECT ANY DATABASE.") + return + except Exception as e: + self._fail(code, diagnosis="Unable to validate CONNECT ANY DATABASE: {}".format(e), rawerror=str(e)) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="CONNECT ANY DATABASE is granted.", + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_view_any_definition(self, cursor: Any) -> None: + code = SQLServerConfigurationError.missing_view_any_definition + try: + if not _has_server_permission(cursor, "VIEW ANY DEFINITION"): + self._fail(code, diagnosis="The Datadog login does not have VIEW ANY DEFINITION.") + return + except Exception as e: + self._fail(code, diagnosis="Unable to validate VIEW ANY DEFINITION: {}".format(e), rawerror=str(e)) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="VIEW ANY DEFINITION is granted.", + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_per_database_access(self, cursor: Any) -> None: + connect_code = SQLServerConfigurationError.per_database_access + view_state_code = SQLServerConfigurationError.missing_per_database_view_state + config = self._check._config + try: + cursor.execute( + "SELECT name FROM sys.databases " + "WHERE state = 0 AND database_id > 4 AND name NOT IN ('master', 'tempdb', 'model', 'msdb')" + ) + rows = cursor.fetchall() or [] + except Exception as e: + self._fail( + connect_code, + diagnosis="Unable to enumerate online databases from sys.databases: {}".format(e), + rawerror=str(e), + ) + return + + candidates = [row[0] for row in rows if row and row[0] and _matches_autodiscovery(row[0], config)] + if not candidates: + self._check.diagnosis.success( + name=connect_code.value, + diagnosis="No autodiscovered databases to probe.", + category=CATEGORY_SQLSERVER, + ) + return + + truncated = len(candidates) > PER_DATABASE_PROBE_LIMIT + sample = candidates[:PER_DATABASE_PROBE_LIMIT] + connect_failures: list[str] = [] + view_state_failures: list[str] = [] + accessible: list[str] = [] + for name in sample: + try: + cursor.execute("USE {}".format(_quote_identifier(name))) + _execute_read_probe(cursor, "SELECT TOP 1 1") + except Exception as e: + connect_failures.append("{}: {}".format(name, e)) + continue + accessible.append(name) + try: + if not _has_database_permission(cursor, "VIEW DATABASE STATE"): + view_state_failures.append(name) + except Exception as e: + view_state_failures.append("{}: {}".format(name, e)) + + self._emit_per_database_connect_result(connect_code, connect_failures, sample, candidates, truncated) + self._emit_per_database_view_state_result(view_state_code, view_state_failures, accessible) + + def _emit_per_database_connect_result( + self, + code: SQLServerConfigurationError, + failures: list[str], + sample: list[str], + candidates: list[str], + truncated: bool, + ) -> None: + if failures: + extra = ( + " (probed first {} of {} autodiscovered databases)".format(len(sample), len(candidates)) + if truncated + else "" + ) + self._fail( + code, + diagnosis="Unable to access {} of {} probed databases{}: {}".format( + len(failures), len(sample), extra, "; ".join(failures) + ), + rawerror="; ".join(failures), + ) + return + + diagnosis = "All {} probed autodiscovered databases are accessible.".format(len(sample)) + if truncated: + diagnosis = "All {} of {} autodiscovered databases probed are accessible (limit reached).".format( + len(sample), len(candidates) + ) + self._check.diagnosis.success(name=code.value, diagnosis=diagnosis, category=CATEGORY_SQLSERVER) + + def _emit_per_database_view_state_result( + self, + code: SQLServerConfigurationError, + failures: list[str], + accessible: list[str], + ) -> None: + if not accessible: + return + if failures: + self._fail( + code, + diagnosis="VIEW DATABASE STATE missing on {} of {} accessible databases: {}".format( + len(failures), len(accessible), ", ".join(failures) + ), + rawerror="; ".join(failures), + ) + return + self._check.diagnosis.success( + name=code.value, + diagnosis="VIEW DATABASE STATE is granted on all {} accessible autodiscovered databases.".format( + len(accessible) + ), + category=CATEGORY_SQLSERVER, + ) + + def _diagnose_msdb_select(self, cursor: Any) -> None: + code = SQLServerConfigurationError.missing_msdb_select + failures = [] + probes = self._msdb_probe_queries() + for table, query in probes: + try: + _execute_read_probe(cursor, query) + except Exception as e: + failures.append("{}: {}".format(table, e)) + + if failures: + self._fail( + code, + diagnosis="Unable to read required msdb tables: {}".format("; ".join(failures)), + rawerror="; ".join(failures), + ) + return + + self._check.diagnosis.success( + name=code.value, + diagnosis="Required msdb tables are readable: {}.".format(", ".join(table for table, _ in probes)), + category=CATEGORY_SQLSERVER, + ) + + def _needs_performance_counters(self) -> bool: + return self._collects_regular_metrics() + + def _needs_view_server_state(self) -> bool: + config = self._check._config + return self._collects_regular_metrics() or config.dbm_enabled + + def _needs_view_database_performance_state(self) -> bool: + if not is_azure_database(self._current_engine_edition()): + return False + config = self._check._config + if config.dbm_enabled: + return True + if not self._collects_regular_metrics(): + return False + return config.database_metrics_config["file_stats_metrics"]["enabled"] + + def _needs_per_database_access(self) -> bool: + config = self._check._config + if not config.autodiscovery: + return False + if is_azure_sql_database(self._current_engine_edition()): + return False + return self._collects_regular_metrics() or config.dbm_enabled + + def _needs_connect_any_database(self) -> bool: + config = self._check._config + return not is_azure_sql_database(self._current_engine_edition()) and ( + config.dbm_enabled or (self._collects_regular_metrics() and config.autodiscovery) + ) + + def _needs_view_any_definition(self) -> bool: + config = self._check._config + database_metrics = config.database_metrics_config + if is_azure_sql_database(self._current_engine_edition()): + return False + if config.dbm_enabled: + return True + if not self._collects_regular_metrics(): + return False + return database_metrics["ao_metrics"]["enabled"] or database_metrics["master_files_metrics"]["enabled"] + + def _needs_msdb_select(self) -> bool: + if is_azure_sql_database(self._current_engine_edition()): + return False + if self._agent_jobs_enabled(): + return True + + config = self._check._config + if not self._collects_regular_metrics(): + return False + + database_metrics = config.database_metrics_config + return ( + database_metrics["db_backup_metrics"]["enabled"] + or database_metrics["primary_log_shipping_metrics"]["enabled"] + or database_metrics["secondary_log_shipping_metrics"]["enabled"] + ) + + def _msdb_probe_queries(self) -> list[tuple[str, str]]: + config = self._check._config + database_metrics = config.database_metrics_config + probes = [] + if self._collects_regular_metrics(): + if database_metrics["db_backup_metrics"]["enabled"]: + probes.append(("msdb.dbo.backupset", "SELECT TOP 1 1 FROM msdb.dbo.backupset")) + if database_metrics["primary_log_shipping_metrics"]["enabled"]: + probes.append( + ( + "msdb.dbo.log_shipping_monitor_primary", + "SELECT TOP 1 1 FROM msdb.dbo.log_shipping_monitor_primary", + ) + ) + if database_metrics["secondary_log_shipping_metrics"]["enabled"]: + probes.append( + ( + "msdb.dbo.log_shipping_monitor_secondary", + "SELECT TOP 1 1 FROM msdb.dbo.log_shipping_monitor_secondary", + ) + ) + if self._agent_jobs_enabled(): + probes.extend( + [ + ("msdb.dbo.sysjobs", "SELECT TOP 1 1 FROM msdb.dbo.sysjobs"), + ("msdb.dbo.sysjobhistory", "SELECT TOP 1 1 FROM msdb.dbo.sysjobhistory"), + ("msdb.dbo.sysjobactivity", "SELECT TOP 1 1 FROM msdb.dbo.sysjobactivity"), + ] + ) + if not self._is_rds: + probes.append(("msdb.dbo.syssessions", "SELECT TOP 1 1 FROM msdb.dbo.syssessions")) + return probes + + def _collects_regular_metrics(self) -> bool: + config = self._check._config + return not config.only_custom_queries and not config.proc + + def _agent_jobs_enabled(self) -> bool: + config = self._check._config + return config.dbm_enabled and is_affirmative(config.agent_jobs_config.get('enabled', False)) + + def _current_engine_edition(self) -> int | None: + return self._engine_edition or self._check.static_info_cache.get(STATIC_INFO_ENGINE_EDITION) + + def _resolved_connector(self) -> str | None: + connection = getattr(self._check, "connection", None) + connector = getattr(connection, "connector", None) + if connector: + return str(connector).lower() + value = self._check.instance.get("connector") + return str(value).lower() if value else None + + def _detect_rds(self, cursor: Any) -> None: + try: + row = _fetchone(cursor, "SELECT name FROM sys.databases WHERE name = 'rdsadmin'") + except Exception: + self._is_rds = False + return + self._is_rds = bool(row) + + def _fail(self, code: SQLServerConfigurationError, diagnosis: str, rawerror: str | None = None) -> None: + self._check.diagnosis.fail( + name=code.value, + diagnosis=diagnosis, + category=CATEGORY_SQLSERVER, + description=DIAGNOSTIC_METADATA[code]["description"], + remediation=build_remediation(code), + rawerror=rawerror, + ) + self._failed.add(code.value) + + def _database_desc(self) -> str: + connection = self._check.connection + database = self._check.instance.get('database', connection.DEFAULT_DATABASE) + return "{} (database={})".format(connection.get_host_with_port(), database) + + def _username_desc(self) -> str: + return self._check.instance.get('username') or "configured authentication" + + +def _list_pyodbc_drivers() -> list[str] | None: + try: + import pyodbc + + return list(pyodbc.drivers()) + except Exception: + return None + + +def _normalize_driver_name(driver: str) -> str: + name = driver.strip() + if name.startswith("{") and name.endswith("}"): + name = name[1:-1] + return name.strip() + + +def _matches_autodiscovery(name: str, config: Any) -> bool: + if name in SYSTEM_DATABASES: + return False + include = getattr(config, "_include_patterns", None) + exclude = getattr(config, "_exclude_patterns", None) + if include is not None and not include.search(name): + return False + if exclude is not None and exclude.search(name): + return False + return True + + +def _quote_identifier(name: str) -> str: + escaped = name.replace("]", "]]") + return "[{}]".format(escaped) + + +def _has_server_permission(cursor: Any, permission: str) -> bool: + row = _fetchone(cursor, "SELECT HAS_PERMS_BY_NAME(NULL, NULL, ?)", (permission,)) + return bool(row and row[0] == 1) + + +def _has_database_permission(cursor: Any, permission: str) -> bool: + # NULL securable targets the current database. Passing DB_NAME() instead would + # be parsed by HAS_PERMS_BY_NAME as a multipart identifier, which returns 0 + # for Azure SQL databases whose names contain a dot. + row = _fetchone(cursor, "SELECT HAS_PERMS_BY_NAME(NULL, 'DATABASE', ?)", (permission,)) + return bool(row and row[0] == 1) + + +def _fetchone(cursor: Any, query: str, params: tuple[Any, ...] | None = None) -> Any: + if params is None: + cursor.execute(query) + else: + cursor.execute(query, params) + return cursor.fetchone() + + +def _execute_read_probe(cursor: Any, query: str) -> None: + cursor.execute(query) + cursor.fetchall() + + +def _to_int(value: Any) -> int | None: + if value is None: + return None + return int(value) diff --git a/sqlserver/datadog_checks/sqlserver/sqlserver.py b/sqlserver/datadog_checks/sqlserver/sqlserver.py index 8d15b54580c5d..45add69eb5a73 100644 --- a/sqlserver/datadog_checks/sqlserver/sqlserver.py +++ b/sqlserver/datadog_checks/sqlserver/sqlserver.py @@ -4,6 +4,7 @@ from __future__ import division +import functools import time from collections import defaultdict from string import Template @@ -103,6 +104,7 @@ VALID_METRIC_TYPES, expected_sys_databases_columns, ) +from datadog_checks.sqlserver.diagnose import run_diagnostics from datadog_checks.sqlserver.metrics import DEFAULT_PERFORMANCE_TABLE, VALID_TABLES from datadog_checks.sqlserver.utils import ( is_azure_sql_database, @@ -196,6 +198,8 @@ def __init__(self, name, init_config, instances): self._database_metrics = None self.sqlserver_incr_fraction_metric_previous_values = {} + self.diagnosis.register(functools.partial(run_diagnostics, self)) + self._submit_initialization_health_event() def initialize_xe_session_handlers(self): diff --git a/sqlserver/tests/test_diagnose.py b/sqlserver/tests/test_diagnose.py new file mode 100644 index 0000000000000..95f9eaa6ed7ca --- /dev/null +++ b/sqlserver/tests/test_diagnose.py @@ -0,0 +1,732 @@ +# (C) Datadog, Inc. 2026-present +# All rights reserved +# Licensed under a 3-clause BSD style license (see LICENSE) +import json +from contextlib import contextmanager + +import pytest + +from datadog_checks.base import ConfigurationError +from datadog_checks.base.utils.diagnose import Diagnosis +from datadog_checks.sqlserver import SQLServer +from datadog_checks.sqlserver.connection_errors import SQLConnectionError +from datadog_checks.sqlserver.const import ENGINE_EDITION_AZURE_MANAGED_INSTANCE, ENGINE_EDITION_SQL_DATABASE +from datadog_checks.sqlserver.diagnose import ( + SQLSERVER_SETUP_DOCS_URL, + SQLSERVER_TROUBLESHOOTING_DOCS_URL, + SQLServerConfigurationError, +) + +from .common import CHECK_NAME + +pytestmark = pytest.mark.unit + + +class FakeCursor: + """Cursor stub that dispatches SQL and params to canned results.""" + + def __init__(self, responses): + self._responses = responses + self._rows = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def execute(self, sql, params=None): + for matcher, result in self._responses: + if callable(matcher): + ok = matcher(sql, params) + else: + ok = matcher in sql + if not ok: + continue + if isinstance(result, Exception): + raise result + self._rows = list(result) + return + raise AssertionError("unexpected query: {!r} params={!r}".format(sql, params)) + + def fetchone(self): + return self._rows[0] if self._rows else None + + def fetchall(self): + return list(self._rows) + + +class FakeConnection: + DEFAULT_DATABASE = "master" + + def __init__(self, responses=None, open_error=None): + self._responses = responses or [] + self._open_error = open_error + self.opened = 0 + self.closed = 0 + + @contextmanager + def open_managed_default_connection(self, key_prefix): + if self._open_error is not None: + raise self._open_error + self.opened += 1 + try: + yield + finally: + self.closed += 1 + + def get_managed_cursor(self, key_prefix): + return FakeCursor(self._responses) + + def get_host_with_port(self): + return "localhost,1433" + + +def _permission(name): + def predicate(sql, params): + return "HAS_PERMS_BY_NAME" in sql and params == (name,) + + predicate.__qualname__ = "_permission({!r})".format(name) + return predicate + + +def _get_diagnoses(check): + check.diagnosis.clear() + return [d._asdict() for d in check.diagnosis.run_explicit()] + + +def _by_name(diagnoses, name): + return [d for d in diagnoses if d['name'] == name] + + +def _check(instance_minimal_defaults, responses, **instance_overrides): + instance = dict(instance_minimal_defaults, **instance_overrides) + check = SQLServer(CHECK_NAME, {}, [instance]) + check._connection = FakeConnection(responses) + return check + + +def _happy_responses( + *, + major_version=16, + engine_edition=2, + view_database_state=True, + view_database_performance_state=True, + connect_any_database=True, + view_any_definition=True, + is_rds=False, + autodiscovered_databases=(), +): + return [ + ("SERVERPROPERTY('ProductMajorVersion')", [(major_version, engine_edition)]), + ("sys.dm_os_performance_counters", [(1,)]), + (_permission("VIEW SERVER STATE"), [(1,)]), + (_permission("VIEW DATABASE STATE"), [(1 if view_database_state else 0,)]), + ("sys.dm_exec_sessions", [(1,)]), + (_permission("VIEW DATABASE PERFORMANCE STATE"), [(1 if view_database_performance_state else 0,)]), + ("sys.dm_io_virtual_file_stats", [(1,)]), + (_permission("CONNECT ANY DATABASE"), [(1 if connect_any_database else 0,)]), + (_permission("VIEW ANY DEFINITION"), [(1 if view_any_definition else 0,)]), + ("'rdsadmin'", [("rdsadmin",)] if is_rds else []), + ("msdb.dbo.backupset", []), + ("msdb.dbo.sysjobs", []), + ("msdb.dbo.sysjobhistory", []), + ("msdb.dbo.sysjobactivity", []), + ("msdb.dbo.syssessions", []), + ("database_id > 4", [(name,) for name in autodiscovered_databases]), + ("SELECT TOP 1 1", [(1,)]), + (lambda sql, params: sql.startswith("USE "), []), + ] + + +def _assert_result(diagnoses, code, result): + rows = _by_name(diagnoses, code.value) + assert rows, "missing diagnosis for {}".format(code.value) + assert rows[0]['result'] == result, rows + + +def _replace_response(responses, matcher_key, new_result): + """Return responses with the entry whose matcher matches matcher_key replaced with new_result. + + String matcher_keys compare equal to string matchers; callable matcher_keys compare via __qualname__. + """ + target = getattr(matcher_key, '__qualname__', matcher_key) + return [ + (matcher, new_result) if getattr(matcher, '__qualname__', matcher) == target else (matcher, result) + for matcher, result in responses + ] + + +def test_standard_diagnostics_success(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses()) + + diagnoses = _get_diagnoses(check) + + for code in ( + SQLServerConfigurationError.connection_failure, + SQLServerConfigurationError.sqlserver_version_unsupported, + SQLServerConfigurationError.performance_counters_not_readable, + SQLServerConfigurationError.missing_view_server_state, + SQLServerConfigurationError.missing_msdb_select, + ): + _assert_result(diagnoses, code, Diagnosis.DIAGNOSIS_SUCCESS) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_connect_any_database.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_any_definition.value) + assert check._connection.opened == 1 + assert check._connection.closed == 1 + + +def test_connection_failure(instance_minimal_defaults): + check = SQLServer(CHECK_NAME, {}, [instance_minimal_defaults]) + check._connection = FakeConnection(open_error=SQLConnectionError("login failed for user")) + + diagnoses = _get_diagnoses(check) + + rows = _by_name(diagnoses, SQLServerConfigurationError.connection_failure.value) + assert len(rows) == 1 + assert rows[0]['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "login failed for user" in rows[0]['diagnosis'] + assert SQLSERVER_SETUP_DOCS_URL in rows[0]['remediation'] + assert SQLSERVER_TROUBLESHOOTING_DOCS_URL in rows[0]['remediation'] + + +def test_connection_configuration_error(instance_minimal_defaults): + check = SQLServer(CHECK_NAME, {}, [instance_minimal_defaults]) + check._connection = FakeConnection(open_error=ConfigurationError("invalid connection string")) + + diagnoses = _get_diagnoses(check) + + rows = _by_name(diagnoses, SQLServerConfigurationError.connection_failure.value) + assert len(rows) == 1 + assert rows[0]['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "invalid connection string" in rows[0]['diagnosis'] + + +def test_unsupported_version_fails(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(major_version=10)) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.sqlserver_version_unsupported.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "below the minimum supported version" in row['diagnosis'] + + +def test_performance_counter_permission_failure(instance_minimal_defaults): + responses = _happy_responses() + responses[1] = ("sys.dm_os_performance_counters", Exception("permission denied")) + check = _check(instance_minimal_defaults, responses) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.performance_counters_not_readable.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "sys.dm_os_performance_counters" in row['diagnosis'] + assert "permission denied" in row['rawerror'] + + +def test_missing_view_server_state_fails(instance_minimal_defaults): + responses = _happy_responses() + responses[2] = (_permission("VIEW SERVER STATE"), [(0,)]) + check = _check(instance_minimal_defaults, responses) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_server_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "VIEW SERVER STATE" in row['diagnosis'] + + +def test_dbm_diagnostics_success(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(), dbm=True) + + diagnoses = _get_diagnoses(check) + + _assert_result(diagnoses, SQLServerConfigurationError.missing_connect_any_database, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.missing_view_any_definition, Diagnosis.DIAGNOSIS_SUCCESS) + + +def test_missing_connect_any_database_fails_for_dbm(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(connect_any_database=False), dbm=True) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_connect_any_database.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "CONNECT ANY DATABASE" in row['diagnosis'] + + +def test_connect_any_database_warns_before_sqlserver_2014(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(major_version=11), dbm=True) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_connect_any_database.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_WARNING + assert "unavailable before SQL Server 2014" in row['diagnosis'] + + +def test_missing_view_any_definition_fails_for_dbm(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(view_any_definition=False), dbm=True) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_any_definition.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "VIEW ANY DEFINITION" in row['diagnosis'] + + +def test_missing_msdb_select_fails_when_enabled_feature_needs_msdb(instance_minimal_defaults): + responses = _replace_response(_happy_responses(), "msdb.dbo.backupset", Exception("permission denied")) + check = _check(instance_minimal_defaults, responses) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "msdb.dbo.backupset" in row['diagnosis'] + assert "permission denied" in row['rawerror'] + + +def test_agent_jobs_adds_msdb_job_table_probes(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(), + dbm=True, + agent_jobs={'enabled': True}, + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_SUCCESS + assert "msdb.dbo.sysjobs" in row['diagnosis'] + assert "msdb.dbo.sysjobhistory" in row['diagnosis'] + assert "msdb.dbo.sysjobactivity" in row['diagnosis'] + assert "msdb.dbo.syssessions" in row['diagnosis'] + + +def test_agent_jobs_skips_syssessions_on_rds(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(is_rds=True), + dbm=True, + agent_jobs={'enabled': True}, + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_SUCCESS + assert "msdb.dbo.syssessions" not in row['diagnosis'] + + +def test_agent_jobs_missing_syssessions_select_fails(instance_minimal_defaults): + responses = _replace_response(_happy_responses(), "msdb.dbo.syssessions", Exception("permission denied")) + check = _check(instance_minimal_defaults, responses, dbm=True, agent_jobs={'enabled': True}) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "msdb.dbo.syssessions" in row['diagnosis'] + assert "permission denied" in row['rawerror'] + + +def test_azure_sql_database_skips_server_and_msdb_specific_dbm_diagnostics(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(engine_edition=5), dbm=True) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_connect_any_database.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_any_definition.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value) + + +def test_azure_sql_database_uses_view_database_state_probe(instance_minimal_defaults): + responses = _replace_response( + _happy_responses(engine_edition=5), + _permission("VIEW SERVER STATE"), + [(0,)], + ) + check = _check(instance_minimal_defaults, responses) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_server_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_SUCCESS + assert "VIEW DATABASE STATE" in row['diagnosis'] + + +def test_azure_sql_database_missing_view_database_state_fails(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(engine_edition=5, view_database_state=False), + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_server_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "VIEW DATABASE STATE" in row['diagnosis'] + assert "VIEW SERVER STATE" not in row['diagnosis'] + + +def test_azure_sql_database_view_database_state_failure(instance_minimal_defaults): + responses = _replace_response( + _happy_responses(engine_edition=5), + "sys.dm_exec_sessions", + Exception("permission denied"), + ) + check = _check(instance_minimal_defaults, responses) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_server_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "VIEW DATABASE STATE" in row['diagnosis'] + assert "VIEW SERVER STATE" not in row['diagnosis'] + assert "permission denied" in row['rawerror'] + + +def test_only_custom_queries_skips_baseline_probes(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(), only_custom_queries=True) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.performance_counters_not_readable.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_server_state.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value) + _assert_result(diagnoses, SQLServerConfigurationError.connection_failure, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.sqlserver_version_unsupported, Diagnosis.DIAGNOSIS_SUCCESS) + + +def test_only_custom_queries_still_probes_dbm_baseline(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(), only_custom_queries=True, dbm=True) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.performance_counters_not_readable.value) + _assert_result(diagnoses, SQLServerConfigurationError.missing_view_server_state, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.missing_connect_any_database, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.missing_view_any_definition, Diagnosis.DIAGNOSIS_SUCCESS) + + +def test_only_custom_queries_skips_database_metric_msdb_probes(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(), + only_custom_queries=True, + database_metrics={'db_backup_metrics': {'enabled': True}}, + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value) + + +def test_stored_procedure_skips_regular_metric_probes(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(), + stored_procedure='pyStoredProc', + database_autodiscovery=True, + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.performance_counters_not_readable.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_server_state.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_connect_any_database.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_any_definition.value) + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_msdb_select.value) + _assert_result(diagnoses, SQLServerConfigurationError.connection_failure, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.sqlserver_version_unsupported, Diagnosis.DIAGNOSIS_SUCCESS) + + +def test_stored_procedure_still_probes_dbm_diagnostics(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(), stored_procedure='pyStoredProc', dbm=True) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.performance_counters_not_readable.value) + _assert_result(diagnoses, SQLServerConfigurationError.missing_view_server_state, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.missing_connect_any_database, Diagnosis.DIAGNOSIS_SUCCESS) + _assert_result(diagnoses, SQLServerConfigurationError.missing_view_any_definition, Diagnosis.DIAGNOSIS_SUCCESS) + + +def test_get_diagnoses_returns_json(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses()) + + parsed = json.loads(check.get_diagnoses()) + + assert any(d['name'] == SQLServerConfigurationError.connection_failure.value for d in parsed) + + +def test_view_database_performance_state_skipped_on_non_azure(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses(), dbm=True) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_database_performance_state.value) + + +def test_view_database_performance_state_success_on_azure_sql_database(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(engine_edition=ENGINE_EDITION_SQL_DATABASE), + dbm=True, + ) + + diagnoses = _get_diagnoses(check) + + _assert_result( + diagnoses, + SQLServerConfigurationError.missing_view_database_performance_state, + Diagnosis.DIAGNOSIS_SUCCESS, + ) + + +def test_view_database_performance_state_missing_permission_fails(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(engine_edition=ENGINE_EDITION_AZURE_MANAGED_INSTANCE, view_database_performance_state=False), + dbm=True, + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_database_performance_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "VIEW DATABASE PERFORMANCE STATE" in row['diagnosis'] + + +def test_view_database_performance_state_probe_failure(instance_minimal_defaults): + responses = _replace_response( + _happy_responses(engine_edition=ENGINE_EDITION_AZURE_MANAGED_INSTANCE), + "sys.dm_io_virtual_file_stats", + Exception("permission denied"), + ) + check = _check(instance_minimal_defaults, responses, dbm=True) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_view_database_performance_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "sys.dm_io_virtual_file_stats" in row['diagnosis'] + assert "permission denied" in row['rawerror'] + + +def test_view_database_performance_state_skipped_when_no_dependent_collection(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(engine_edition=ENGINE_EDITION_SQL_DATABASE), + database_metrics={'file_stats_metrics': {'enabled': False}}, + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_view_database_performance_state.value) + + +def test_odbc_driver_not_installed_fails(instance_minimal_defaults, monkeypatch): + from datadog_checks.sqlserver import diagnose as diagnose_module + + monkeypatch.setattr(diagnose_module, "_list_pyodbc_drivers", lambda: ["FreeTDS"]) + check = _check( + instance_minimal_defaults, + _happy_responses(), + connector='odbc', + driver='{ODBC Driver 18 for SQL Server}', + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.odbc_driver_not_installed.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "ODBC Driver 18 for SQL Server" in row['diagnosis'] + assert "FreeTDS" in row['diagnosis'] + + +def test_odbc_driver_installed_does_not_fail(instance_minimal_defaults, monkeypatch): + from datadog_checks.sqlserver import diagnose as diagnose_module + + monkeypatch.setattr(diagnose_module, "_list_pyodbc_drivers", lambda: ["ODBC Driver 18 for SQL Server"]) + check = _check( + instance_minimal_defaults, + _happy_responses(), + connector='odbc', + driver='{ODBC Driver 18 for SQL Server}', + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.odbc_driver_not_installed.value) + + +def test_odbc_driver_check_skipped_for_adodbapi(instance_minimal_defaults, monkeypatch): + from datadog_checks.sqlserver import diagnose as diagnose_module + + monkeypatch.setattr(diagnose_module, "_list_pyodbc_drivers", lambda: []) + check = _check( + instance_minimal_defaults, + _happy_responses(), + connector='adodbapi', + adoprovider='MSOLEDBSQL19', + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.odbc_driver_not_installed.value) + + +def test_odbc_driver_check_skipped_when_pyodbc_unavailable(instance_minimal_defaults, monkeypatch): + from datadog_checks.sqlserver import diagnose as diagnose_module + + monkeypatch.setattr(diagnose_module, "_list_pyodbc_drivers", lambda: None) + check = _check( + instance_minimal_defaults, + _happy_responses(), + connector='odbc', + driver='{ODBC Driver 18 for SQL Server}', + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.odbc_driver_not_installed.value) + + +def test_per_database_access_success(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(autodiscovered_databases=("db_a", "db_b")), + database_autodiscovery=True, + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.per_database_access.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_SUCCESS + assert "2" in row['diagnosis'] + + +def test_per_database_access_reports_failed_database(instance_minimal_defaults): + def use_db_bad(sql, params): + return sql == "USE [db_bad]" + + def use_db_ok(sql, params): + return sql == "USE [db_ok]" + + responses = _happy_responses(autodiscovered_databases=("db_ok", "db_bad")) + responses.insert(0, (use_db_bad, Exception("USE failed for db_bad"))) + responses.insert(1, (use_db_ok, [])) + + check = _check(instance_minimal_defaults, responses, database_autodiscovery=True) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.per_database_access.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "db_bad" in row['diagnosis'] + assert "USE failed for db_bad" in row['rawerror'] + + +def test_per_database_access_applies_autodiscovery_filters(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(autodiscovered_databases=("included_db", "skipped_db")), + database_autodiscovery=True, + autodiscovery_include=['included.*'], + autodiscovery_exclude=['skipped.*'], + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.per_database_access.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_SUCCESS + assert "1" in row['diagnosis'] + + +def test_per_database_access_skipped_without_autodiscovery(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses()) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.per_database_access.value) + + +def test_per_database_access_skipped_on_azure_sql_database(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(engine_edition=ENGINE_EDITION_SQL_DATABASE), + database_autodiscovery=True, + dbm=True, + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.per_database_access.value) + + +def test_per_database_view_state_success(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(autodiscovered_databases=("db_a", "db_b")), + database_autodiscovery=True, + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_per_database_view_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_SUCCESS + assert "2" in row['diagnosis'] + + +def test_per_database_view_state_missing_on_all_accessible_dbs(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(autodiscovered_databases=("db_a", "db_b"), view_database_state=False), + database_autodiscovery=True, + ) + + diagnoses = _get_diagnoses(check) + + row = _by_name(diagnoses, SQLServerConfigurationError.missing_per_database_view_state.value)[0] + assert row['result'] == Diagnosis.DIAGNOSIS_FAIL + assert "db_a" in row['diagnosis'] + assert "db_b" in row['diagnosis'] + assert "VIEW DATABASE STATE" in row['diagnosis'] + + +def test_per_database_view_state_skipped_when_no_dbs_accessible(instance_minimal_defaults): + def use_any_db(sql, params): + return sql.startswith("USE [") + + responses = _happy_responses(autodiscovered_databases=("db_a", "db_b")) + responses.insert(0, (use_any_db, Exception("login not authorized"))) + check = _check(instance_minimal_defaults, responses, database_autodiscovery=True) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_per_database_view_state.value) + + +def test_per_database_view_state_skipped_without_autodiscovery(instance_minimal_defaults): + check = _check(instance_minimal_defaults, _happy_responses()) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_per_database_view_state.value) + + +def test_per_database_view_state_skipped_on_azure_sql_database(instance_minimal_defaults): + check = _check( + instance_minimal_defaults, + _happy_responses(engine_edition=ENGINE_EDITION_SQL_DATABASE, autodiscovered_databases=("db_a",)), + database_autodiscovery=True, + dbm=True, + ) + + diagnoses = _get_diagnoses(check) + + assert not _by_name(diagnoses, SQLServerConfigurationError.missing_per_database_view_state.value)