@cryptotaxi247 / netdata-1 / commits / 84c4ef12a

feat(go.d.plugin): improve db function e2e tests and documentation (#21654)

* docs: add node-states-and-transitions to learn map Add the new node states documentation page to the navigation map under Netdata Cloud section with appropriate keywords. * feat(go.d.plugin): improve db function e2e tests and documentation Test improvements: - Add assert_column_visibility() to catch columns with missing Visible flag - Add assert_unique_key_populated() to catch NULL digest handling issues - Add syntax error testing for MySQL (error 1064 with NULL DIGEST) - Add visibility checks for error-info, deadlock-info, and top-queries - Add UniqueKey validation to error-info tests Documentation (metadata.yaml): - Document ring_buffer vs event_file Extended Events targets - Add clear warnings that ring_buffer is NOT recommended for production - Document Microsoft's recommendation to use event_file with Azure Blob Storage - Add configuration examples for Azure SQL with ring_buffer (with warnings) - Document all new config options (deadlock_info_use_ring_buffer, etc.) These tests would have caught the bugs fixed in commits f270a87070 and afd66a9952 where MySQL columns were not visible (Visible: true missing) and NULL digest rows were being skipped. * feat(go.d.plugin/postgres): add matrix tests for PostgreSQL 14-18 - Add POSTGRES_IMAGE variable to docker-compose.yml - Update postgres.sh to support variant labels and add visibility check - Add postgres-matrix.sh to test PostgreSQL 14, 15, 16, 17, and 18 All 5 versions pass metrics collection and top-queries function tests. * fix: remove metadata.yaml changes per review request Ilya is working on refactoring function options documentation separately. Removing changes to avoid conflicts.

Costa Tsaousis committed Jan 27, 2026 at 16:21 UTC 84c4ef12aac7bc11db5c3b4dd383804916f777ad
6 files changed +258 -12
src/go/tools/functions-validation/docker-compose.yml
+1 -1
@@ -1,6 +1,6 @@
1 services:
2 postgres:
3 - image: postgres:16
3 + image: ${POSTGRES_IMAGE:-postgres:16}
4 environment:
5 POSTGRES_USER: netdata
6 POSTGRES_PASSWORD: netdata
src/go/tools/functions-validation/e2e/lib.sh
+192
@@ -280,3 +280,195 @@ sys.exit(0 if len(rows) >= min_rows else 1)
280 PY
281 fi
282 }
283 +
284 +# Assert column visibility rules:
285 +# - If fewer than 5 columns exist, ALL must be visible
286 +# - Otherwise, at least 5 columns must be visible
287 +assert_column_visibility() {
288 + local input="$1"
289 + local context="${2:-response}"
290 +
291 + if command -v python3 >/dev/null 2>&1; then
292 + python3 - "$input" "$context" <<'PY'
293 +import json
294 +import sys
295 +
296 +path = sys.argv[1]
297 +context = sys.argv[2]
298 +
299 +with open(path, "r", encoding="utf-8") as fh:
300 + doc = json.load(fh)
301 +
302 +columns = doc.get("columns") or {}
303 +
304 +# Build list of columns with their visibility
305 +col_list = []
306 +if isinstance(columns, dict):
307 + for field, col in columns.items():
308 + if isinstance(col, dict):
309 + col_list.append({"field": field, "visible": col.get("visible", False)})
310 +else:
311 + for col in columns:
312 + if isinstance(col, dict):
313 + col_list.append({"field": col.get("field", ""), "visible": col.get("visible", False)})
314 +
315 +total = len(col_list)
316 +visible_count = sum(1 for c in col_list if c["visible"] is True)
317 +
318 +if total < 5:
319 + # All columns must be visible
320 + if visible_count != total:
321 + invisible = [c["field"] for c in col_list if c["visible"] is not True]
322 + raise SystemExit(
323 + f"{context}: All {total} columns must be visible (fewer than 5 total), "
324 + f"but only {visible_count} are visible. Invisible columns: {invisible}"
325 + )
326 +else:
327 + # At least 5 columns must be visible
328 + if visible_count < 5:
329 + invisible = [c["field"] for c in col_list if c["visible"] is not True]
330 + raise SystemExit(
331 + f"{context}: At least 5 columns must be visible, "
332 + f"but only {visible_count} of {total} are visible. Invisible columns: {invisible}"
333 + )
334 +PY
335 + else
336 + python - "$input" "$context" <<'PY'
337 +import json
338 +import sys
339 +
340 +path = sys.argv[1]
341 +context = sys.argv[2]
342 +
343 +with open(path, "r") as fh:
344 + doc = json.load(fh)
345 +
346 +columns = doc.get("columns") or {}
347 +
348 +col_list = []
349 +if isinstance(columns, dict):
350 + for field, col in columns.items():
351 + if isinstance(col, dict):
352 + col_list.append({"field": field, "visible": col.get("visible", False)})
353 +else:
354 + for col in columns:
355 + if isinstance(col, dict):
356 + col_list.append({"field": col.get("field", ""), "visible": col.get("visible", False)})
357 +
358 +total = len(col_list)
359 +visible_count = sum(1 for c in col_list if c["visible"] is True)
360 +
361 +if total < 5:
362 + if visible_count != total:
363 + invisible = [c["field"] for c in col_list if c["visible"] is not True]
364 + raise SystemExit(
365 + "%s: All %d columns must be visible (fewer than 5 total), "
366 + "but only %d are visible. Invisible columns: %s" % (context, total, visible_count, invisible)
367 + )
368 +else:
369 + if visible_count < 5:
370 + invisible = [c["field"] for c in col_list if c["visible"] is not True]
371 + raise SystemExit(
372 + "%s: At least 5 columns must be visible, "
373 + "but only %d of %d are visible. Invisible columns: %s" % (context, visible_count, total, invisible)
374 + )
375 +PY
376 + fi
377 +}
378 +
379 +# Assert UniqueKey column has non-empty values in all rows
380 +assert_unique_key_populated() {
381 + local input="$1"
382 + local context="${2:-response}"
383 +
384 + if command -v python3 >/dev/null 2>&1; then
385 + python3 - "$input" "$context" <<'PY'
386 +import json
387 +import sys
388 +
389 +path = sys.argv[1]
390 +context = sys.argv[2]
391 +
392 +with open(path, "r", encoding="utf-8") as fh:
393 + doc = json.load(fh)
394 +
395 +columns = doc.get("columns") or {}
396 +data = doc.get("data") or []
397 +
398 +# Find UniqueKey column index
399 +unique_key_idx = None
400 +unique_key_field = None
401 +
402 +if isinstance(columns, dict):
403 + for field, col in columns.items():
404 + if isinstance(col, dict) and col.get("unique_key") is True:
405 + unique_key_idx = col.get("index")
406 + unique_key_field = field
407 + break
408 +else:
409 + for idx, col in enumerate(columns):
410 + if isinstance(col, dict) and col.get("unique_key") is True:
411 + unique_key_idx = idx
412 + unique_key_field = col.get("field", f"column_{idx}")
413 + break
414 +
415 +if unique_key_idx is None:
416 + # No UniqueKey column defined, skip check
417 + sys.exit(0)
418 +
419 +# Check all rows have non-empty UniqueKey
420 +for i, row in enumerate(data):
421 + if unique_key_idx >= len(row):
422 + raise SystemExit(f"{context}: Row {i} missing UniqueKey column (index {unique_key_idx})")
423 + val = row[unique_key_idx]
424 + if val is None or str(val).strip() == "":
425 + raise SystemExit(
426 + f"{context}: Row {i} has empty UniqueKey ({unique_key_field}) - "
427 + f"deduplication will fail. This may indicate NULL digest handling is broken."
428 + )
429 +PY
430 + else
431 + python - "$input" "$context" <<'PY'
432 +import json
433 +import sys
434 +
435 +path = sys.argv[1]
436 +context = sys.argv[2]
437 +
438 +with open(path, "r") as fh:
439 + doc = json.load(fh)
440 +
441 +columns = doc.get("columns") or {}
442 +data = doc.get("data") or []
443 +
444 +unique_key_idx = None
445 +unique_key_field = None
446 +
447 +if isinstance(columns, dict):
448 + for field, col in columns.items():
449 + if isinstance(col, dict) and col.get("unique_key") is True:
450 + unique_key_idx = col.get("index")
451 + unique_key_field = field
452 + break
453 +else:
454 + for idx, col in enumerate(columns):
455 + if isinstance(col, dict) and col.get("unique_key") is True:
456 + unique_key_idx = idx
457 + unique_key_field = col.get("field", "column_%d" % idx)
458 + break
459 +
460 +if unique_key_idx is None:
461 + sys.exit(0)
462 +
463 +for i, row in enumerate(data):
464 + if unique_key_idx >= len(row):
465 + raise SystemExit("%s: Row %d missing UniqueKey column (index %d)" % (context, i, unique_key_idx))
466 + val = row[unique_key_idx]
467 + if val is None or str(val).strip() == "":
468 + raise SystemExit(
469 + "%s: Row %d has empty UniqueKey (%s) - "
470 + "deduplication will fail. This may indicate NULL digest handling is broken." % (context, i, unique_key_field)
471 + )
472 +PY
473 + fi
474 +}
src/go/tools/functions-validation/e2e/mssql.sh
+6
@@ -1454,6 +1454,9 @@ verify_deadlock_info() {
1454 echo "deadlock-info did not produce valid deadlock attribution after 5 attempts" >&2
1455 return 1
1456 fi
1457 +
1458 + # Verify column visibility rules
1459 + assert_column_visibility "$output" "deadlock-info"
1460 }
1461
1462 verify_deadlock_info_no_deadlock
@@ -1515,10 +1518,13 @@ sleep 2
1518
1519 error_output="$(run_mssql_function_with_retry error-info '__job:local' 'true')"
1520 assert_error_info_has_errors "$error_output"
1521 +assert_column_visibility "$error_output" "error-info"
1522 +assert_unique_key_populated "$error_output" "error-info"
1523
1524 run_mssql_top_queries_with_retry
1525 assert_top_queries_error_attribution_active "$WORKDIR/mssql-top-queries.json"
1526 assert_top_queries_error_attribution_mapped "$WORKDIR/mssql-top-queries.json" "$WORKDIR/mssql-error-info.json"
1527 assert_top_queries_plan_ops "$WORKDIR/mssql-top-queries.json"
1528 +assert_column_visibility "$WORKDIR/mssql-top-queries.json" "top-queries"
1529
1530 echo "E2E checks passed for ${MSSQL_VARIANT_LABEL}." >&2
src/go/tools/functions-validation/e2e/mysql.sh
+32 -10
@@ -544,20 +544,26 @@ def normalize(val):
544 # Error categories to verify:
545 # 1146 - Table doesn't exist (missing_table)
546 # 1062 - Duplicate key (constraint violation)
547 -# Note: Syntax errors (1064) are not captured in events_statements_history
548 -# because they fail during parsing before instrumentation.
547 +# 1064 - Syntax error (should be captured with synthetic digest)
548 error_categories = {
550 - "table_not_found": {"patterns": ["missing_table", "doesn't exist", "does not exist"], "found": False},
551 - "duplicate_key": {"patterns": ["duplicate", "primary", "unique"], "found": False},
549 + "table_not_found": {"patterns": ["missing_table", "doesn't exist", "does not exist"], "error_nums": [1146], "found": False},
550 + "duplicate_key": {"patterns": ["duplicate", "primary", "unique"], "error_nums": [1062], "found": False},
551 + "syntax_error": {"patterns": ["syntax", "error in your sql"], "error_nums": [1064], "found": False},
552 }
553
554 for row in data:
555 if num_idx >= len(row) or row[num_idx] is None:
556 continue
557 + err_num = row[num_idx]
558 msg = normalize(row[err_idx]).lower()
559 for cat, info in error_categories.items():
560 if info["found"]:
561 continue
562 + # Check by error number first
563 + if err_num in info.get("error_nums", []):
564 + info["found"] = True
565 + continue
566 + # Fallback to pattern matching
567 for pattern in info["patterns"]:
568 if pattern in msg:
569 info["found"] = True
@@ -624,20 +630,24 @@ def normalize(val):
630 # Error categories to verify:
631 # 1146 - Table doesn't exist (missing_table)
632 # 1062 - Duplicate key (constraint violation)
627 -# Note: Syntax errors (1064) are not captured in events_statements_history
628 -# because they fail during parsing before instrumentation.
633 +# 1064 - Syntax error (should be captured with synthetic digest)
634 error_categories = {
630 - "table_not_found": {"patterns": ["missing_table", "doesn't exist", "does not exist"], "found": False},
631 - "duplicate_key": {"patterns": ["duplicate", "primary", "unique"], "found": False},
635 + "table_not_found": {"patterns": ["missing_table", "doesn't exist", "does not exist"], "error_nums": [1146], "found": False},
636 + "duplicate_key": {"patterns": ["duplicate", "primary", "unique"], "error_nums": [1062], "found": False},
637 + "syntax_error": {"patterns": ["syntax", "error in your sql"], "error_nums": [1064], "found": False},
638 }
639
640 for row in data:
641 if num_idx >= len(row) or row[num_idx] is None:
642 continue
643 + err_num = row[num_idx]
644 msg = normalize(row[err_idx]).lower()
645 for cat, info in error_categories.items():
646 if info["found"]:
647 continue
648 + if err_num in info.get("error_nums", []):
649 + info["found"] = True
650 + continue
651 for pattern in info["patterns"]:
652 if pattern in msg:
653 info["found"] = True
@@ -912,6 +922,9 @@ verify_deadlock_info() {
922 echo "deadlock-info did not produce valid deadlock attribution after 5 attempts" >&2
923 return 1
924 fi
925 +
926 + # Verify column visibility rules
927 + assert_column_visibility "$output" "deadlock-info"
928 }
929
930 verify_deadlock_info_no_deadlock
@@ -935,20 +948,29 @@ for _ in 1 2 3; do
948 done
949
950 # 2. Duplicate key / constraint violation (error 1062)
938 -# Note: Syntax errors (1064) are NOT captured in events_statements_history
939 -# because they fail during parsing before instrumentation records them.
951 for _ in 1 2 3; do
952 mysql_exec_root_allow_error "INSERT INTO error_test (id, unique_col, int_col) VALUES (1, 'new_value', 200);"
953 mysql_exec_root_allow_error "INSERT INTO error_test (id, unique_col, int_col) VALUES (99, 'existing_value', 300);"
954 done
955
956 +# 3. Syntax errors (error 1064)
957 +# These have NULL DIGEST in performance_schema because they fail during parsing
958 +# before instrumentation. The collector should generate a synthetic digest.
959 +for _ in 1 2 3; do
960 + mysql_exec_root_allow_error "SELECT * FROM WHERE 1=1;"
961 + mysql_exec_root_allow_error "SELEC * FROM error_test;"
962 +done
963 +
964 sleep 1
965
966 error_output="$(run_function mysql error-info '__job:local' 'true')"
967 assert_error_info_has_errors "$error_output"
968 +assert_column_visibility "$error_output" "error-info"
969 +assert_unique_key_populated "$error_output" "error-info"
970
971 run_top_queries mysql
972 assert_top_queries_error_attribution_enabled "$WORKDIR/mysql-top-queries.json"
973 +assert_column_visibility "$WORKDIR/mysql-top-queries.json" "top-queries"
974
975 restore_statement_history_consumers
976
src/go/tools/functions-validation/e2e/postgres-matrix.sh new
+20
@@ -0,0 +1,20 @@
1 +#!/usr/bin/env bash
2 +set -euo pipefail
3 +
4 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5 +
6 +# PostgreSQL versions 14-18 (all currently supported versions)
7 +# Version 13 reached EOL in November 2025
8 +POSTGRES_VARIANTS=(
9 + "postgres:14|postgres-14"
10 + "postgres:15|postgres-15"
11 + "postgres:16|postgres-16"
12 + "postgres:17|postgres-17"
13 + "postgres:18|postgres-18"
14 +)
15 +
16 +for entry in "${POSTGRES_VARIANTS[@]}"; do
17 + IFS='|' read -r image label <<< "$entry"
18 + printf '\n=== Running PostgreSQL collector E2E for %s (%s) ===\n' "$label" "$image" >&2
19 + POSTGRES_IMAGE="$image" POSTGRES_VARIANT="$label" bash "$SCRIPT_DIR/postgres.sh"
20 +done
src/go/tools/functions-validation/e2e/postgres.sh
+7 -1
@@ -12,11 +12,17 @@ POSTGRES_PORT="$(reserve_port)"
12 write_env "POSTGRES_PORT" "$POSTGRES_PORT"
13 replace_in_file "$WORKDIR/config/go.d/postgres.conf" "127.0.0.1:5432" "127.0.0.1:${POSTGRES_PORT}"
14
15 +POSTGRES_VARIANT_LABEL="${POSTGRES_VARIANT:-postgres}"
16 +if [ -n "${POSTGRES_IMAGE:-}" ]; then
17 + write_env "POSTGRES_IMAGE" "$POSTGRES_IMAGE"
18 +fi
19 +
20 compose_up postgres
21 wait_healthy postgres 90
22
23 build_plugin
24 run_info postgres
25 run_top_queries postgres
26 +assert_column_visibility "$WORKDIR/postgres-top-queries.json" "top-queries"
27
22 -echo "E2E checks passed for postgres." >&2
28 +echo "E2E checks passed for ${POSTGRES_VARIANT_LABEL}." >&2