Skip to content

Commit 092dffc

Browse files
authored
[DBMON-6003] Add Postgres unit tests to track all config default value changes (DataDog#22160)
* Add unit tests to track all config default value changes * ddev format
1 parent ec4653d commit 092dffc

1 file changed

Lines changed: 358 additions & 0 deletions

File tree

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# (C) Datadog, Inc. 2025-present
2+
# All rights reserved
3+
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
5+
"""
6+
The goal of these tests are to verify our default configuration values are correct and remain consistent.
7+
Failing tests indicate a regression in our defaults and should be looked inspected carefully.
8+
Default values are duplicated within this file by design to ensure tests fail if the value changes unexpectedly.
9+
"""
10+
11+
from unittest.mock import MagicMock
12+
13+
import pytest
14+
15+
from datadog_checks.postgres.config import build_config
16+
17+
# Single source of truth for all expected default values
18+
# Organized by category for readability
19+
EXPECTED_DEFAULTS = {
20+
# === Required fields (no defaults) ===
21+
'host': None, # Required, user must provide
22+
'username': None, # Required, user must provide
23+
'password': None, # Required, user must provide (optional for managed auth)
24+
# === Connection configuration ===
25+
'port': 5432,
26+
'dbname': 'postgres',
27+
'ssl': 'allow',
28+
'ssl_root_cert': None,
29+
'ssl_cert': None,
30+
'ssl_key': None,
31+
'ssl_password': None,
32+
'query_timeout': 5000,
33+
'idle_connection_timeout': 60000,
34+
'max_connections': 30,
35+
'application_name': 'datadog-agent',
36+
# === Identification ===
37+
'reported_hostname': None,
38+
'exclude_hostname': False,
39+
'data_directory': '/usr/local/pgsql/data',
40+
'database_identifier': {
41+
'template': '$resolved_hostname',
42+
},
43+
# === Metric collection toggles ===
44+
'dbstrict': False,
45+
'collect_function_metrics': False,
46+
'collect_count_metrics': True,
47+
'collect_checksum_metrics': False,
48+
'collect_activity_metrics': False,
49+
'collect_buffercache_metrics': False,
50+
'collect_database_size_metrics': True,
51+
'collect_default_database': True,
52+
'collect_bloat_metrics': False,
53+
'collect_wal_metrics': True,
54+
'tag_replication_role': True,
55+
'table_count_limit': 200,
56+
'max_relations': 300,
57+
# === Database filtering ===
58+
'ignore_databases': [
59+
'template0',
60+
'template1',
61+
'rdsadmin',
62+
'azure_maintenance',
63+
'cloudsqladmin',
64+
'alloydbadmin',
65+
'alloydbmetadata',
66+
],
67+
'ignore_schemas_owned_by': [
68+
'rds_superuser',
69+
'rdsadmin',
70+
],
71+
# === Database monitoring (DBM) ===
72+
'dbm': False,
73+
'pg_stat_statements_view': 'pg_stat_statements',
74+
'pg_stat_activity_view': 'pg_stat_activity',
75+
'log_unobfuscated_queries': False,
76+
'log_unobfuscated_plans': False,
77+
'database_instance_collection_interval': 300,
78+
# === DBM: Query metrics ===
79+
'query_metrics': {
80+
'enabled': True,
81+
'collection_interval': 10,
82+
'pg_stat_statements_max_warning_threshold': 10000,
83+
'incremental_query_metrics': False,
84+
'baseline_metrics_expiry': 300,
85+
'full_statement_text_cache_max_size': 10000,
86+
'full_statement_text_samples_per_hour_per_query': 1,
87+
'run_sync': False,
88+
},
89+
# === DBM: Query samples ===
90+
'query_samples': {
91+
'enabled': True,
92+
'collection_interval': 1,
93+
'explain_function': 'datadog.explain_statement',
94+
'explained_queries_per_hour_per_query': 60,
95+
'samples_per_hour_per_query': 15,
96+
'explained_queries_cache_maxsize': 5000,
97+
'seen_samples_cache_maxsize': 10000,
98+
'explain_parameterized_queries': True,
99+
'explain_errors_cache_maxsize': 5000,
100+
'explain_errors_cache_ttl': 86400,
101+
'run_sync': False,
102+
},
103+
# === DBM: Query activity ===
104+
'query_activity': {
105+
'enabled': True,
106+
'collection_interval': 10,
107+
'payload_row_limit': 3500,
108+
},
109+
# === DBM: Settings collection ===
110+
'collect_settings': {
111+
'enabled': True,
112+
'collection_interval': 600,
113+
'run_sync': False,
114+
'ignored_settings_patterns': ['plpgsql%'],
115+
},
116+
# === DBM: Schema collection ===
117+
'collect_schemas': {
118+
'enabled': False,
119+
'max_tables': 300,
120+
'max_columns': 50,
121+
'collection_interval': 600,
122+
'max_query_duration': 60,
123+
},
124+
# === DBM: Obfuscator options ===
125+
'obfuscator_options': {
126+
'obfuscation_mode': 'obfuscate_and_normalize',
127+
'replace_digits': False,
128+
'collect_metadata': True,
129+
'collect_tables': True,
130+
'collect_commands': True,
131+
'collect_comments': True,
132+
'keep_sql_alias': True,
133+
'keep_dollar_quoted_func': True,
134+
'remove_space_between_parentheses': False,
135+
'keep_null': False,
136+
'keep_boolean': False,
137+
'keep_positional_parameter': False,
138+
'keep_trailing_semicolon': False,
139+
'keep_identifier_quotation': False,
140+
'keep_json_path': False,
141+
},
142+
# === DBM: Database autodiscovery ===
143+
'database_autodiscovery': {
144+
'enabled': False,
145+
'global_view_db': 'postgres',
146+
'max_databases': 100,
147+
'refresh': 600,
148+
'exclude': ['cloudsqladmin', 'rdsadmin', 'alloydbadmin', 'alloydbmetadata'],
149+
'include': ['.*'],
150+
},
151+
# === DBM: Lock metrics ===
152+
'locks_idle_in_transaction': {
153+
'enabled': True,
154+
'collection_interval': 300,
155+
'max_rows': 100,
156+
},
157+
# === DBM: Raw query statements ===
158+
'collect_raw_query_statement': {
159+
'enabled': False,
160+
},
161+
# === Relations configuration ===
162+
'relations': [],
163+
# === Query encodings ===
164+
'query_encodings': ['utf8'],
165+
# === Activity metrics ===
166+
'activity_metrics_excluded_aggregations': [],
167+
# === Cloud provider configurations ===
168+
'aws': {
169+
'instance_endpoint': None,
170+
'region': None,
171+
'managed_authentication': {'enabled': None},
172+
},
173+
'azure': {
174+
'deployment_type': None,
175+
'fully_qualified_domain_name': None,
176+
'managed_authentication': {'enabled': None},
177+
},
178+
'gcp': {
179+
'project_id': None,
180+
'instance_id': None,
181+
},
182+
# === Tagging ===
183+
'tags': ('server:localhost', 'port:5432', 'db:postgres'), # Dynamically generated from connection info
184+
'disable_generic_tags': False,
185+
'propagate_agent_tags': False,
186+
# === Custom metrics/queries (deprecated/user-provided) ===
187+
'custom_metrics': (), # Deprecated field, defaults to empty tuple
188+
'custom_queries': (), # User-provided queries, defaults to empty tuple
189+
'only_custom_queries': False, # Flag to run only custom queries
190+
'use_global_custom_queries': 'true', # Use custom queries from init_config
191+
'service': None, # User-provided service name
192+
'metric_patterns': None, # User-provided patterns
193+
# === Agent standard fields ===
194+
'min_collection_interval': 15.0, # Standard Agent field
195+
'empty_default_hostname': False, # Deprecated field
196+
}
197+
198+
199+
@pytest.fixture
200+
def mock_check():
201+
"""Mock check object with a warning method."""
202+
check = MagicMock()
203+
check.warning = MagicMock()
204+
return check
205+
206+
207+
@pytest.fixture
208+
def minimal_instance():
209+
"""Minimal instance configuration with only required fields."""
210+
return {
211+
'host': 'localhost',
212+
'username': 'testuser',
213+
'password': 'testpass',
214+
}
215+
216+
217+
pytestmark = pytest.mark.unit
218+
219+
220+
def test_all_config_defaults(mock_check, minimal_instance):
221+
"""
222+
Verify that all InstanceConfig fields have the expected default values.
223+
224+
This test iterates through every field in InstanceConfig and validates its default.
225+
If a field is missing from EXPECTED_DEFAULTS, the test will fail with instructions.
226+
"""
227+
from datadog_checks.postgres.config_models.instance import InstanceConfig
228+
229+
# Build config with minimal instance
230+
mock_check.instance = minimal_instance
231+
mock_check.init_config = {}
232+
config, result = build_config(check=mock_check)
233+
234+
# Get all fields from InstanceConfig
235+
all_fields = set(InstanceConfig.__annotations__.keys())
236+
237+
# Check for fields in InstanceConfig that aren't in EXPECTED_DEFAULTS
238+
missing_from_expected = all_fields - set(EXPECTED_DEFAULTS.keys())
239+
if missing_from_expected:
240+
error_msg = (
241+
f"\n\n{'=' * 80}\n"
242+
f"MISSING EXPECTED DEFAULTS!\n"
243+
f"{'=' * 80}\n\n"
244+
f"The following fields exist in InstanceConfig but are missing from EXPECTED_DEFAULTS:\n"
245+
f" {sorted(missing_from_expected)}\n\n"
246+
f"To fix this:\n"
247+
f"Add each field to the EXPECTED_DEFAULTS dictionary in test_config_defaults.py\n"
248+
f"with its expected default value.\n\n"
249+
f"Examples:\n"
250+
f" - Simple field: 'field_name': default_value,\n"
251+
f" - No default (user-provided): 'field_name': None,\n"
252+
f" - Nested object: 'field_name': {{'key': 'value'}},\n"
253+
f" - Array: 'field_name': ['item1', 'item2'],\n\n"
254+
f"{'=' * 80}\n"
255+
)
256+
pytest.fail(error_msg)
257+
258+
# Check for fields in EXPECTED_DEFAULTS that aren't in InstanceConfig (cleanup needed)
259+
extra_in_expected = set(EXPECTED_DEFAULTS.keys()) - all_fields
260+
if extra_in_expected:
261+
pytest.fail(
262+
f"\n\nThe following fields are in EXPECTED_DEFAULTS but don't exist in InstanceConfig:\n"
263+
f" {sorted(extra_in_expected)}\n"
264+
f"These should be removed from EXPECTED_DEFAULTS."
265+
)
266+
267+
# Required fields that come from minimal_instance - skip validation since they're user-provided
268+
SKIP_VALIDATION = {'host', 'username', 'password'}
269+
270+
# Now validate each field's actual default against expected
271+
failures = []
272+
273+
for field_name, expected_value in EXPECTED_DEFAULTS.items():
274+
if field_name in SKIP_VALIDATION:
275+
continue
276+
277+
try:
278+
actual_value = getattr(config, field_name)
279+
except AttributeError:
280+
failures.append(f"{field_name}: Field not accessible on config object")
281+
continue
282+
283+
# Handle different types of comparisons
284+
if isinstance(expected_value, dict):
285+
# For nested objects, recursively check fields
286+
if not _compare_nested_object(actual_value, expected_value, field_name, failures):
287+
continue
288+
elif isinstance(expected_value, (list, tuple)):
289+
# Convert actual value to same type for comparison
290+
if isinstance(actual_value, (list, tuple)):
291+
actual_as_type = type(expected_value)(actual_value)
292+
if actual_as_type != expected_value:
293+
failures.append(f"{field_name}: expected {expected_value!r}, got {actual_value!r}")
294+
elif actual_value is None:
295+
failures.append(f"{field_name}: expected {expected_value!r}, got None")
296+
else:
297+
failures.append(f"{field_name}: expected {expected_value!r}, got {actual_value!r}")
298+
else:
299+
# Simple value comparison
300+
if actual_value != expected_value:
301+
failures.append(f"{field_name}: expected {expected_value!r}, got {actual_value!r}")
302+
303+
if failures:
304+
error_msg = (
305+
f"\n\n{'=' * 80}\n"
306+
f"DEFAULT VALUE MISMATCHES DETECTED!\n"
307+
f"{'=' * 80}\n\n"
308+
f"The following fields have default values that don't match expectations:\n\n"
309+
+ "\n".join(f" • {failure}" for failure in failures)
310+
+ f"\n\n"
311+
f"This indicates either:\n"
312+
f" 1. A regression in default values (BAD - investigate carefully!)\n"
313+
f" 2. EXPECTED_DEFAULTS needs to be updated to match new behavior\n\n"
314+
f"{'=' * 80}\n"
315+
)
316+
pytest.fail(error_msg)
317+
318+
319+
def _compare_nested_object(actual_obj, expected_dict, field_path, failures):
320+
"""
321+
Recursively compare a nested config object against expected dictionary.
322+
323+
Returns True if all comparisons passed, False if any failed (failures list is updated).
324+
"""
325+
all_passed = True
326+
327+
for key, expected_value in expected_dict.items():
328+
try:
329+
actual_value = getattr(actual_obj, key)
330+
except AttributeError:
331+
failures.append(f"{field_path}.{key}: Field not accessible")
332+
all_passed = False
333+
continue
334+
335+
if isinstance(expected_value, dict):
336+
# Recursively check nested objects
337+
if not _compare_nested_object(actual_value, expected_value, f"{field_path}.{key}", failures):
338+
all_passed = False
339+
elif isinstance(expected_value, (list, tuple)):
340+
# Convert actual value to same type for comparison
341+
if isinstance(actual_value, (list, tuple)):
342+
actual_as_type = type(expected_value)(actual_value)
343+
if actual_as_type != expected_value:
344+
failures.append(f"{field_path}.{key}: expected {expected_value!r}, got {actual_value!r}")
345+
all_passed = False
346+
elif actual_value is None:
347+
failures.append(f"{field_path}.{key}: expected {expected_value!r}, got None")
348+
all_passed = False
349+
else:
350+
failures.append(f"{field_path}.{key}: expected {expected_value!r}, got {actual_value!r}")
351+
all_passed = False
352+
else:
353+
# Simple value comparison
354+
if actual_value != expected_value:
355+
failures.append(f"{field_path}.{key}: expected {expected_value!r}, got {actual_value!r}")
356+
all_passed = False
357+
358+
return all_passed

0 commit comments

Comments
 (0)