Repository: Automattic/remote-data-blocks
Pull request #465: Custom query monitor panel, improve validation issue reporting
Author: login: chriszarate
Labels: name: enhancement
description: New feature or request
Created: 2025-04-16T20:27:50Z | Merged: 2025-04-22T12:37:50Z

This PR introduces a custom Query Monitor panel for Remote Data Blocks, with accompanying improvements to how we report validation errors.

Currently, we pipe these errors to the default Logs panel. We include more information than is helpful and it is very difficult to parse and read. It looks like this:

<p><img width="1512" alt="Screenshot 2025-04-16 at 16 17 32" src="https://github.com/user-attachments/assets/89cfc240-30ff-4cb2-b9b2-069a1b64edba" /></p>

With this PR, we construct a descriptive path using PHP syntax that more directly points to the issue. We also withhold context that is of little value in favor of a more direct stack trace:

<p><img width="1511" alt="Screenshot 2025-04-16 at 16 24 43" src="https://github.com/user-attachments/assets/89b9ae61-4320-4384-83f8-960c5b13d912" /></p>

These issues are also reported in their own dedicated panel. In future PRs, we can add additional panels dedicated to other kinds of issues or logs (query executions, caching, etc).


Diff:
diff --git a/.github/workflows/scripts/generate-playground-blueprint.js b/.github/workflows/scripts/generate-playground-blueprint.js
index 7594d31a1..5a8fd6043 100644
--- a/.github/workflows/scripts/generate-playground-blueprint.js
+++ b/.github/workflows/scripts/generate-playground-blueprint.js
@@ -15,7 +15,7 @@ async function run( { github, context } ) {
 		}
 	}
 
-	const body = `Test this PR in [WordPress Playground](https://wordpress-playground.atomicsites.blog/#{"landingPage":"/wp-admin/admin.php?page=remote-data-blocks-settings","features":{"networking":true},"login":true,"preferredVersions":{"php":"8.2","wp":"latest"},"steps":[{"step":"setSiteOptions","options":{"blogname":"Remote%20Data%20Blocks%20PR#${ context.issue.number }","blogdescription":"Explore%20the%20Remote%20Data%20Blocks%20plugin%20in%20a%20WordPress%20Playground"}},{"step":"defineWpConfigConsts","consts":{"USE_PLAYGROUND_CORS_PROXY":true}},{"step":"installPlugin","pluginData":{"caption":"Installing%20RDB","resource":"url","url":"https://wordpress-playground.atomicsites.blog/plugin-proxy.php?org=Automattic&repo=remote-data-blocks&workflow=Build%20Live%20Branch&artifact=remote-data-blocks-${ context.issue.number }&pr=${ context.issue.number }"},"options":{"activate":true,"targetFolderName":"remote-data-blocks"}}]}).`;
+	const body = `Test this PR in [WordPress Playground](https://wordpress-playground.atomicsites.blog/#{"landingPage":"/wp-admin/admin.php?page=remote-data-blocks-settings","features":{"networking":true},"login":true,"preferredVersions":{"php":"8.2","wp":"latest"},"steps":[{"step":"setSiteOptions","options":{"blogname":"Remote%20Data%20Blocks%20PR#${ context.issue.number }","blogdescription":"Explore%20the%20Remote%20Data%20Blocks%20plugin%20in%20a%20WordPress%20Playground"}},{"step":"installPlugin","pluginData":{"caption":"Installing%20RDB","resource":"url","url":"https://wordpress-playground.atomicsites.blog/plugin-proxy.php?org=Automattic&repo=remote-data-blocks&workflow=Build%20Live%20Branch&artifact=remote-data-blocks-${ context.issue.number }&pr=${ context.issue.number }"},"options":{"activate":true,"targetFolderName":"remote-data-blocks"}}]}).`;
 
 	if ( existingCommentId ) {
 		await github.rest.issues.updateComment( {
diff --git a/blueprint.json b/blueprint.json
index 59ee8ea1b..ad6e7d3ae 100644
--- a/blueprint.json
+++ b/blueprint.json
@@ -23,12 +23,6 @@
         "blogdescription": "Explore the Remote Data Blocks plugin in a WordPress Playground"
       }
     },
-    {
-      "step": "defineWpConfigConsts",
-      "consts": {
-        "USE_PLAYGROUND_CORS_PROXY": true
-      }
-    },
     {
       "step": "installPlugin",
       "options": {
diff --git a/blueprint.local.json b/blueprint.local.json
index 634b37687..918cff4c0 100644
--- a/blueprint.local.json
+++ b/blueprint.local.json
@@ -23,12 +23,6 @@
         "blogdescription": "Explore the Remote Data Blocks plugin in a WordPress Playground"
       }
     },
-    {
-      "step": "defineWpConfigConsts",
-      "consts": {
-        "USE_PLAYGROUND_CORS_PROXY": false
-      }
-    },
     {
       "step": "activatePlugin",
       "pluginPath": "remote-data-blocks/remote-data-blocks.php"
diff --git a/inc/Config/ArraySerializable.php b/inc/Config/ArraySerializable.php
index 9f0a705ca..82535c121 100644
--- a/inc/Config/ArraySerializable.php
+++ b/inc/Config/ArraySerializable.php
@@ -58,7 +58,7 @@ final public static function from_array( array $config, ?ValidatorInterface $val
 
 		$schema = static::get_config_schema();
 
-		$validator = $validator ?? new Validator( $schema, static::class );
+		$validator = $validator ?? new Validator( $schema, static::class, '$config' );
 		$validated = $validator->validate( $config );
 
 		if ( is_wp_error( $validated ) ) {
diff --git a/inc/Config/DataSource/HttpDataSource.php b/inc/Config/DataSource/HttpDataSource.php
index 1be71bde3..fb0eb01c2 100644
--- a/inc/Config/DataSource/HttpDataSource.php
+++ b/inc/Config/DataSource/HttpDataSource.php
@@ -45,7 +45,7 @@ final public function get_service_name(): string {
 	 */
 	public static function preprocess_config( array $config ): array|WP_Error {
 		$service_config = $config['service_config'] ?? [];
-		$validator = new Validator( static::get_service_config_schema() );
+		$validator = new Validator( static::get_service_config_schema(), static::class, '$service_config' );
 		$validated = $validator->validate( $service_config );
 
 		if ( is_wp_error( $validated ) ) {
diff --git a/inc/Editor/BlockManagement/ConfigRegistry.php b/inc/Editor/BlockManagement/ConfigRegistry.php
index 435b99c7c..a6d0c5567 100644
--- a/inc/Editor/BlockManagement/ConfigRegistry.php
+++ b/inc/Editor/BlockManagement/ConfigRegistry.php
@@ -34,7 +34,7 @@ public static function init( ?LoggerInterface $logger = null ): void {
 	public static function register_block( array $user_config = [] ): bool|WP_Error {
 		// Validate the provided user configuration.
 		$schema = ConfigSchemas::get_remote_data_block_config_schema();
-		$validator = new Validator( $schema, static::class );
+		$validator = new Validator( $schema, static::class, '$user_config' );
 		$validated = $validator->validate( $user_config );
 
 		if ( is_wp_error( $validated ) ) {
diff --git a/inc/HttpClient/WPRemoteRequestHandler.php b/inc/HttpClient/WPRemoteRequestHandler.php
index d473de1cf..3f95094c3 100644
--- a/inc/HttpClient/WPRemoteRequestHandler.php
+++ b/inc/HttpClient/WPRemoteRequestHandler.php
@@ -32,18 +32,6 @@ public function __invoke( RequestInterface $request, array $options ): PromiseIn
 			$url = (string) $request->getUri();
 			$headers = [];
 
-			// If we are running on WordPress Playground, use the provided CORS proxy.
-			if ( defined( 'USE_PLAYGROUND_CORS_PROXY' ) && true === USE_PLAYGROUND_CORS_PROXY ) {
-				$new_uri = $request->getUri()
-					->withHost( 'playground.wordpress.net' )
-					->withScheme( 'https' )
-					->withPath( '/cors-proxy.php' )
-					->withQuery( $url );
-				$request = $request->withUri( $new_uri );
-				$url = (string) $new_uri;
-				$headers['X-Cors-Proxy-Allowed-Request-Headers'] = 'Authorization';
-			}
-
 			$args = [
 				'body' => (string) $request->getBody(), // Stream has been read, let __toString() rewind and read it.
 				'headers' => $headers,
diff --git a/inc/Logging/QueryMonitor/QueryMonitor.php b/inc/Logging/QueryMonitor/QueryMonitor.php
new file mode 100644
index 000000000..db696f3bc
--- /dev/null
+++ b/inc/Logging/QueryMonitor/QueryMonitor.php
@@ -0,0 +1,52 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Collectors;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+class QueryMonitor {
+	public static function init(): void {
+		add_filter( 'qm/collectors', [ __CLASS__, 'add_collectors' ], 90, 1 );
+		add_filter( 'qm/outputter/html', [ __CLASS__, 'add_outputters' ], 90, 1 );
+	}
+
+	public static function add_collectors( array $collectors ): array {
+		$collector_classes = [
+			'RemoteDataBlocks\Logging\QueryMonitor\RdbMainCollector',
+			'RemoteDataBlocks\Logging\QueryMonitor\RdbValidationCollector',
+		];
+
+		foreach ( $collector_classes as $class ) {
+			if ( class_exists( $class ) ) {
+				$instance = new $class();
+				$collectors[ $instance->id ] = $instance;
+			}
+		}
+  
+		return $collectors;
+	}
+
+	public static function add_outputters( array $outputters ): array {
+		$outputter_classes = [
+			'RemoteDataBlocks\Logging\QueryMonitor\RdbMainOutputHtml',
+			'RemoteDataBlocks\Logging\QueryMonitor\RdbValidationOutputHtml',
+		];
+
+		foreach ( $outputter_classes as $class ) {
+			if ( class_exists( $class ) ) {
+				/**
+				 * @psalm-suppress UndefinedClass
+				 */
+				$collector = QM_Collectors::get( $class::$collector_id );
+				$instance = new $class( $collector );
+				$outputters[ $collector->id ] = $instance;
+			}
+		}
+
+		return $outputters;
+	}
+}
diff --git a/inc/Logging/QueryMonitor/RdbMainCollector.php b/inc/Logging/QueryMonitor/RdbMainCollector.php
new file mode 100644
index 000000000..2c3d13438
--- /dev/null
+++ b/inc/Logging/QueryMonitor/RdbMainCollector.php
@@ -0,0 +1,54 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Collector;
+use QM_Data_Fallback;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+if ( class_exists( 'QM_Collector' ) ) {
+	/**
+	 * @extends QM_Collector<QM_Data_Fallback>
+	 */
+	class RdbMainCollector extends QM_Collector {
+		/**
+		 * @var string
+		 */
+		public $id = 'remote-data-blocks';
+
+		/**
+		 * Intentionally do not call parent
+		 */
+		public function set_up(): void {
+			$this->data->plugin = [
+				'version' => defined( 'REMOTE_DATA_BLOCKS__PLUGIN_VERSION' ) ? constant( 'REMOTE_DATA_BLOCKS__PLUGIN_VERSION' ) : 'unknown',
+			];
+		}
+
+		public function tear_down(): void {}
+
+		/**
+		 * @return array<int, string>
+		 */
+		public function get_concerned_actions(): array {
+			return [];
+		}
+
+		/**
+		 * @return array<int, string>
+		 */
+		public function get_concerned_filters(): array {
+			return [];
+		}
+
+		/**
+		 * @return array<int, string>
+		 */
+		public function get_concerned_constants(): array {
+			return [];
+		}
+	}
+}
diff --git a/inc/Logging/QueryMonitor/RdbMainOutputHtml.php b/inc/Logging/QueryMonitor/RdbMainOutputHtml.php
new file mode 100644
index 000000000..f4b52f705
--- /dev/null
+++ b/inc/Logging/QueryMonitor/RdbMainOutputHtml.php
@@ -0,0 +1,48 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Output_Html;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+if ( class_exists( 'QM_Output_Html' ) ) {
+	class RdbMainOutputHtml extends QM_Output_Html {
+		public static string $collector_id = 'remote-data-blocks';
+
+		public function __construct( RdbMainCollector $collector ) {
+			parent::__construct( $collector );
+			add_filter( 'qm/output/menus', [ $this, 'admin_menu' ], 5, 1 ); // early
+		}
+	
+		public function name(): string {
+			return 'Remote Data Blocks';
+		}
+
+		public function output(): void {
+			$data = $this->collector->get_data();
+
+			$this->before_non_tabular_output();
+
+			echo '<section>';
+			echo '<h3>' . esc_html__( 'Plugin', 'remote-data-blocks' ) . '</h3>';
+			echo '<table>';
+
+			foreach ( array(
+				'version' => __( 'Version', 'remote-data-blocks' ),
+			) as $item => $name ) {
+				echo '<tr>';
+				echo '<th scope="row">' . esc_html( $name ) . '</td>';
+				echo '<td class="qm-ltr qm-wrap">' . esc_html( $data['plugin'][ $item ] ?? 'unknown' ) . '</td>';
+				echo '</tr>';
+			}
+
+			echo '</table>';
+			echo '</section>';
+
+			$this->after_non_tabular_output();
+		}
+	}
+}
diff --git a/inc/Logging/QueryMonitor/RdbValidationCollector.php b/inc/Logging/QueryMonitor/RdbValidationCollector.php
new file mode 100644
index 000000000..d0c0b5931
--- /dev/null
+++ b/inc/Logging/QueryMonitor/RdbValidationCollector.php
@@ -0,0 +1,101 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Backtrace;
+use QM_Collector_Logger;
+use RemoteDataBlocks\Config\ArraySerializable;
+use RemoteDataBlocks\Validation\Validator;
+use WP_Error;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+if ( class_exists( 'QM_Collector_Logger' ) ) {
+	/**
+	 * @extends QM_Collector_Logger<QM_Data_Logger>
+	 */
+	class RdbValidationCollector extends QM_Collector_Logger {
+		/**
+		 * @var string
+		 */
+		public $id = 'remote-data-blocks-validation';
+
+		/**
+		 * Intentionally do not call parent
+		 */
+		public function set_up(): void {
+			// We intentionally do not call parent::set_up() here since that
+			// constructor adds actions that we don't want to hook into.
+			add_action( Validator::ACTION_NAME, [ $this, 'log_validation_issue' ], 90, 1 );
+
+			$this->data->counts = array_fill_keys( $this->get_levels(), 0 );
+		}
+
+		public function tear_down(): void {
+			remove_action( Validator::ACTION_NAME, [ $this, 'error' ], 90, 1 );
+		}
+
+		/**
+		 * @return array<int, string>
+		 */
+		public function get_concerned_actions(): array {
+			return [];
+		}
+
+		/**
+		 * @return array<int, string>
+		 */
+		public function get_concerned_filters(): array {
+			return [];
+		}
+
+		/**
+		 * @return array<int, string>
+		 */
+		public function get_concerned_constants(): array {
+			return [];
+		}
+
+		public function log_validation_issue( WP_Error $error ): void {
+			$error_data = $error->get_error_data();
+			$context = $error_data['context'] ?? null;
+			$level = $error_data['level'] ?? 'error';
+
+			$this->log( $level, $error->get_error_message(), $context );
+		}
+
+		/**
+		 * This method must match the implementation of the parent class,
+		 * including the missing type hints.
+		 *
+		 * @param string $level Log level (e.g., 'error', 'warning', 'info')
+		 * @param string $message Log message
+		 * @param array<string, mixed> $context Context
+		 * @param string|null $prefix Log message prefix
+		 * @return void
+		 */
+		// phpcs:ignore SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint,SlevomatCodingStandard.TypeHints.ReturnTypeHint.MissingNativeTypeHint
+		protected function store( $level, $message, array $context = array(), ?string $prefix = null ) {
+			$trace = new QM_Backtrace( [
+				'ignore_class' => [
+					self::class => true,
+					ArraySerializable::class => true,
+					Validator::class => true,
+				],
+				'ignore_hook' => array(
+					current_filter() => true,
+				),
+			] );
+
+			$this->data->counts[ $level ] = ( $this->data->counts[ $level ] ?? 0 ) + 1;
+			$this->data->logs[] = [
+				'message' => self::interpolate( $message, $context, $prefix ),
+				'filtered_trace' => $trace->get_filtered_trace(),
+				'component' => $trace->get_component(),
+				'level' => $level,
+			];
+		}
+	}
+}
diff --git a/inc/Logging/QueryMonitor/RdbValidationOutputHtml.php b/inc/Logging/QueryMonitor/RdbValidationOutputHtml.php
new file mode 100644
index 000000000..ccf11dc69
--- /dev/null
+++ b/inc/Logging/QueryMonitor/RdbValidationOutputHtml.php
@@ -0,0 +1,40 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Output_Html_Logger;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+if ( class_exists( 'QM_Output_Html_Logger' ) ) {
+	class RdbValidationOutputHtml extends QM_Output_Html_Logger {
+		public static string $collector_id = 'remote-data-blocks-validation';
+
+		public function admin_menu( array $menu ): array {
+			/** @var QM_Data_Logger $data */
+			$data = $this->collector->get_data();
+			$count = 0;
+
+			if ( ! empty( $data->logs ) ) {
+				$count = count( $data->logs );
+
+				/* translators: %s: Number of logs that are available */
+				$label = __( 'Validation (%s)', 'query-monitor' );
+			} else {
+				$label = __( 'Validation', 'query-monitor' );
+			}
+
+			$menu['qm-remote-data-blocks']['children'][ $this->collector->id() ] = $this->menu( array(
+				'id' => 'qm-remote-data-blocks-validation',
+				'title' => esc_html( sprintf(
+					$label,
+					number_format_i18n( $count )
+				) ),
+			) );
+
+			return $menu;
+		}
+	}
+}
diff --git a/inc/Validation/Validator.php b/inc/Validation/Validator.php
index f8aaa65bd..2103dead0 100644
--- a/inc/Validation/Validator.php
+++ b/inc/Validation/Validator.php
@@ -4,7 +4,6 @@
 
 use RemoteDataBlocks\Config\ArraySerializableInterface;
 use RemoteDataBlocks\Validation\Types;
-use RemoteDataBlocks\Logging\LoggerManager;
 use WP_Error;
 use function is_email;
 
@@ -12,21 +11,15 @@
  * Validator class.
  */
 final class Validator implements ValidatorInterface {
-	public function __construct( private array $schema, private string $description = 'Validator' ) {}
+	public const ACTION_NAME = 'remote_data_blocks_validation_issue';
+
+	public function __construct( private array $schema, private string $entity_name = 'Unknown entity', private string $root_path_name = '$value' ) {}
 
 	public function validate( mixed $data ): bool|WP_Error {
-		$validation = $this->check_type( $this->schema, $data );
+		$validation = $this->check_type( $this->schema, $this->root_path_name, $data );
 
 		if ( is_wp_error( $validation ) ) {
-			$error_message = sprintf( '[%s] %s', $this->description, $validation->get_error_message() );
-
-			$child_error = $validation->get_error_data()['child'] ?? null;
-			while ( is_wp_error( $child_error ) ) {
-				$error_message .= sprintf( '; %s', $child_error->get_error_message() );
-				$child_error = $child_error->get_error_data()['child'] ?? null;
-			}
-
-			LoggerManager::instance()->error( $error_message );
+			do_action( self::ACTION_NAME, $validation );
 			return $validation;
 		}
 
@@ -37,17 +30,18 @@ public function validate( mixed $data ): bool|WP_Error {
 	 * Validate a value recursively against a schema.
 	 *
 	 * @param array<string, mixed> $type The schema to validate against.
+	 * @param string $path The PHP-syntax path to the value being validated.
 	 * @param mixed $value The value to validate.
 	 * @return bool|WP_Error Returns true if the data is valid, otherwise a WP_Error.
 	 */
-	private function check_type( array $type, mixed $value = null ): bool|WP_Error {
+	private function check_type( array $type, string $path, mixed $value = null ): bool|WP_Error {
 		if ( Types::is_nullable( $type ) && is_null( $value ) ) {
 			return true;
 		}
 
 		if ( Types::is_primitive( $type ) ) {
 			$type_name = Types::get_type_name( $type );
-			$result = $this->check_primitive_type( $type_name, $value );
+			$result = $this->check_primitive_type( $type_name, $path, $value );
 
 			if ( true === $result ) {
 				return true;
@@ -57,10 +51,10 @@ private function check_type( array $type, mixed $value = null ): bool|WP_Error {
 				return $result;
 			}
 
-			return $this->create_error( sprintf( 'Value must be a %s', $type_name ), $value );
+			return $this->create_error( sprintf( 'must be a %s', $type_name ), $path );
 		}
 
-		return $this->check_non_primitive_type( $type, $value );
+		return $this->check_non_primitive_type( $type, $path, $value );
 	}
 
 	/**
@@ -69,50 +63,55 @@ private function check_type( array $type, mixed $value = null ): bool|WP_Error {
 	 * return the value directly or check it with is_wp_error().
 	 *
 	 * @param array<string, mixed> $type The schema type to validate against.
+	 * @param string $path The PHP-syntax path to the value being validated.
 	 * @param mixed $value The value to validate.
 	 * @return bool|WP_Error Returns true if the data is valid, otherwise a WP_Error.
 	 */
-	private function check_non_primitive_type( array $type, mixed $value ): bool|WP_Error {
+	private function check_non_primitive_type( array $type, string $path, mixed $value ): bool|WP_Error {
 		switch ( Types::get_type_name( $type ) ) {
 			case 'callable':
 				if ( is_callable( $value ) ) {
 					return true;
 				}
 
-				return $this->create_error( 'Value must be callable', $value );
+				return $this->create_error( 'must be callable', $path );
 
 			case 'const':
-				if ( Types::get_type_args( $type ) === $value ) {
+				$const_value = Types::get_type_args( $type );
+				if ( $const_value === $value ) {
 					return true;
 				}
 
-				return $this->create_error( 'Value must be the constant', $value );
+				return $this->create_error( sprintf( 'must equal the constant "%s"', $const_value ), $path );
 
 			case 'enum':
-				if ( in_array( $value, Types::get_type_args( $type ), true ) ) {
+				$enum_values = Types::get_type_args( $type );
+				if ( in_array( $value, $enum_values, true ) ) {
 					return true;
 				}
 
-				return $this->create_error( 'Value must be one of the enumerated values', $value );
+				return $this->create_error( sprintf( 'must be one of the enumerated values: %s', join( ', ', $enum_values ) ), $path );
 
 			case 'instance_of':
-				if ( is_a( $value, Types::get_type_args( $type ) ) ) {
+				$class_name = Types::get_type_args( $type );
+				if ( is_a( $value, $class_name ) ) {
 					return true;
 				}
 
-				return $this->create_error( 'Value must be an instance of the specified class', $value );
+				return $this->create_error( sprintf( 'must be an instance of class "%s"', $class_name ), $path );
 
 			case 'list_of':
 				if ( ! is_array( $value ) || ! array_is_list( $value ) ) {
-					return $this->create_error( 'Value must be a non-associative array', $value );
+					return $this->create_error( 'must be a non-associative array', $path );
 				}
 
 				$member_type = Types::get_type_args( $type );
 
-				foreach ( $value as $item ) {
-					$validated = $this->check_type( $member_type, $item );
+				foreach ( $value as $index => $item ) {
+					$child_path = sprintf( '%s[%s]', $path, strval( $index ) );
+					$validated = $this->check_type( $member_type, $child_path, $item );
 					if ( is_wp_error( $validated ) ) {
-						return $this->create_error( 'Value must be a list of the specified type', $item, $validated );
+						return $validated;
 					}
 				}
 
@@ -124,8 +123,9 @@ private function check_non_primitive_type( array $type, mixed $value ): bool|WP_
 				// the caller can inspect each of them.
 				$errors = [];
 
-				foreach ( Types::get_type_args( $type ) as $member_type ) {
-					$validated = $this->check_type( $member_type, $value );
+				$member_types = Types::get_type_args( $type );
+				foreach ( $member_types as $member_type ) {
+					$validated = $this->check_type( $member_type, $path, $value );
 					if ( true === $validated ) {
 						return true;
 					}
@@ -133,20 +133,23 @@ private function check_non_primitive_type( array $type, mixed $value ): bool|WP_
 					$errors[] = $validated;
 				}
 
-				$error = new WP_Error( 'invalid_one_of_type', 'Validation errors for each of the specified types', [ 'errors' => $errors ] );
+				$member_type_names = array_map( static function ( array $type ): string {
+					return Types::get_type_name( $type );
+				}, $member_types );
 
-				return $this->create_error( 'Value must be one of the specified types', $value, $error );
+				return $this->create_error( sprintf( 'must match one of the specified types: %s', join( ', ', $member_type_names ) ), $path, [ 'child_errors' => $errors ] );
 
 			case 'object':
 				if ( ! self::check_iterable_object( $value ) ) {
-					return $this->create_error( 'Value must be an associative array', $value );
+					return $this->create_error( 'must be an associative array', $path );
 				}
 
 				foreach ( Types::get_type_args( $type ) as $key => $property_type ) {
+					$child_path = sprintf( '%s[\'%s\']', $path, $key );
 					$property_value = $this->get_object_key( $value, $key );
-					$validated = $this->check_type( $property_type, $property_value );
+					$validated = $this->check_type( $property_type, $child_path, $property_value );
 					if ( is_wp_error( $validated ) ) {
-						return $this->create_error( 'Object must have valid property', $key, $validated );
+						return $validated;
 					}
 				}
 
@@ -154,7 +157,7 @@ private function check_non_primitive_type( array $type, mixed $value ): bool|WP_
 
 			case 'record':
 				if ( ! self::check_iterable_object( $value ) ) {
-					return $this->create_error( 'Value must be an associative array', $value );
+					return $this->create_error( 'must be an associative array', $path );
 				}
 
 				$type_args = Types::get_type_args( $type );
@@ -162,55 +165,56 @@ private function check_non_primitive_type( array $type, mixed $value ): bool|WP_
 				$value_type = $type_args[1];
 
 				foreach ( $value as $key => $record_value ) {
-					$validated = $this->check_type( $key_type, $key );
+					$child_path = sprintf( 'Key %s of %s', $key, $path );
+					$validated = $this->check_type( $key_type, $child_path, $key );
 					if ( is_wp_error( $validated ) ) {
-						return $this->create_error( 'Record must have valid key', $key );
+						return $validated;
 					}
 
-					$validated = $this->check_type( $value_type, $record_value );
+					$child_path = sprintf( '%s[\'%s\']', $path, $key );
+					$validated = $this->check_type( $value_type, $child_path, $record_value );
 					if ( is_wp_error( $validated ) ) {
-						return $this->create_error( 'Record must have valid value', $record_value, $validated );
+						return $validated;
 					}
 				}
 
 				return true;
 
 			case 'ref':
-				return $this->check_type( Types::load_ref_type( $type ), $value );
+				return $this->check_type( Types::load_ref_type( $type ), $path, $value );
 
 			case 'serialized_config_for':
 				if ( ! self::check_iterable_object( $value ) ) {
-					return $this->create_error( 'Value must be an associative array', $value );
+					return $this->create_error( 'must be an associative array', $path );
 				}
 
 				$class_ref = Types::get_type_args( $type );
 
 				if ( ! class_exists( $class_ref ) && ! interface_exists( $class_ref ) ) {
-					return $this->create_error( 'Class does not exist', $class_ref );
+					return $this->create_error( sprintf( 'targets non-existent class "%s"', $class_ref ), $path );
 				}
 
 				$implements = class_implements( $class_ref );
 				if ( ! in_array( ArraySerializableInterface::class, $implements, true ) ) {
-					return $this->create_error( 'Class does not implement ArraySerializableInterface', $class_ref );
+					return $this->create_error( sprintf( 'targets class "%s" that does not implement ArraySerializableInterface', $class_ref ), $path );
 				}
 
 				// The config must provide a `__class` property so that we know which
 				// class to inflate. This allows values to target subclasses of the
 				// specified class and also provides disambiguation when the type is
 				// used in a union type (one_of).
-				$subclass = $value[ ArraySerializableInterface::CLASS_REF_ATTRIBUTE ] ?? null;
+				$class_property = ArraySerializableInterface::CLASS_REF_ATTRIBUTE;
+				$subclass = $value[ $class_property ] ?? null;
 				if ( null === $subclass ) {
-					return $this->create_error( 'Value does not provide a __class property', $class_ref );
+					return $this->create_error( sprintf( 'does not provide a %s property', $class_property ), $path );
 				}
 
-				$class_description = sprintf( 'Class %s specified by %s property', $subclass, ArraySerializableInterface::CLASS_REF_ATTRIBUTE );
-
 				if ( ! class_exists( $subclass ) ) {
-					return $this->create_error( $class_description . ' does not exist', $subclass );
+					return $this->create_error( sprintf( 'targets non-existent subclass "%s"', $subclass ), $path );
 				}
 
 				if ( $subclass !== $class_ref && ! is_subclass_of( $subclass, $class_ref, true ) ) {
-					return $this->create_error( $class_description . ' must match or be a subclass of the target class', $subclass );
+					return $this->create_error( sprintf( 'targets subclass "%s" that does not match or extend target class "%s"', $subclass, $class_ref ), $path );
 				}
 
 				// Done with type validation, update the target class so we can validate
@@ -219,7 +223,7 @@ private function check_non_primitive_type( array $type, mixed $value ): bool|WP_
 
 				// Validate the schema for the class we want to instantiate. Call the
 				// config prepocessor since some classes inflate their own config.
-				$config_validator = new Validator( $class_ref::get_config_schema(), $class_ref );
+				$config_validator = new Validator( $class_ref::get_config_schema(), $class_ref, $path );
 				$config = $class_ref::preprocess_config( $value );
 
 				return $config_validator->validate( $config );
@@ -227,18 +231,28 @@ private function check_non_primitive_type( array $type, mixed $value ): bool|WP_
 			case 'string_matching':
 				$regex = Types::get_type_args( $type );
 
-				if ( $this->check_primitive_type( 'string', $value ) && $this->check_primitive_type( 'string', $regex ) && preg_match( $regex, $value ) ) {
+				if ( $this->check_primitive_type( 'string', $path, $value ) && $this->check_primitive_type( 'string', $path, $regex ) && preg_match( $regex, $value ) ) {
 					return true;
 				}
 
-				return $this->create_error( 'Value must match the specified regex', $value );
+				return $this->create_error( sprintf( 'must match regex "%s"', strval( $regex ) ), $path );
 
 			default:
-				return $this->create_error( 'Unknown type', Types::get_type_name( $type ) );
+				return $this->create_error( sprintf( 'is unknown type "%s"', Types::get_type_name( $type ) ), $path );
 		}
 	}
 
-	private function check_primitive_type( string $type_name, mixed $value ): bool|WP_Error {
+	/**
+	 * Validate a primitive value against a schema. This method returns true
+	 * or a WP_Error object. Never check the return value for truthiness; either
+	 * return the value directly or check it with is_wp_error().
+	 *
+	 * @param string $type_name The string name of the schema type to validate against.
+	 * @param string $path The PHP-syntax path to the value being validated.
+	 * @param mixed $value The value to validate.
+	 * @return bool|WP_Error Returns true if the data is valid, otherwise a WP_Error.
+	 */
+	private function check_primitive_type( string $type_name, string $path, mixed $value ): bool|WP_Error {
 		switch ( $type_name ) {
 			case 'any':
 				return true;
@@ -281,7 +295,7 @@ private function check_primitive_type( string $type_name, mixed $value ): bool|W
 				return wp_is_uuid( $value );
 
 			default:
-				return $this->create_error( 'Unknown type', $type_name );
+				return $this->create_error( sprintf( 'is unknown type "%s"', $type_name ), $path );
 		}
 	}
 
@@ -294,10 +308,13 @@ public static function check_iterable_object( mixed $value ): bool {
 		return is_array( $value ) && ( ! array_is_list( $value ) || empty( $value ) );
 	}
 
-	private function create_error( string $message, mixed $value, ?WP_Error $child_error = null ): WP_Error {
-		$serialized_value = is_string( $value ) || is_numeric( $value ) ? strval( $value ) : wp_json_encode( $value );
-		$message = sprintf( '%s: %s', esc_html( $message ), $serialized_value );
-		return new WP_Error( 'invalid_type', $message, [ 'child' => $child_error ] );
+	private function create_error( string $message, string $path, array $context = [] ): WP_Error {
+		return new WP_Error( 'invalid_type', esc_html( sprintf( '%s %s', $path, $message ) ), [
+			'context' => $context,
+			'entity' => $this->entity_name,
+			'level' => 'error',
+			'path' => $path,
+		] );
 	}
 
 	private function get_object_key( mixed $data, string|int $key ): mixed {
diff --git a/remote-data-blocks.php b/remote-data-blocks.php
index f48d5f8c9..a9d423269 100644
--- a/remote-data-blocks.php
+++ b/remote-data-blocks.php
@@ -58,6 +58,9 @@
 // REST endpoints
 REST\RemoteDataController::init();
 
+// QueryMonitor panel
+Logging\QueryMonitor\QueryMonitor::init();
+
 // Fire action to indicate that the plugin is loaded
 do_action( 'remote_data_blocks_loaded' );
 
diff --git a/tests/inc/Config/RemoteDataBlockAttributeTest.php b/tests/inc/Config/RemoteDataBlockAttributeTest.php
index 6999e5260..6f9ef22d2 100644
--- a/tests/inc/Config/RemoteDataBlockAttributeTest.php
+++ b/tests/inc/Config/RemoteDataBlockAttributeTest.php
@@ -128,7 +128,7 @@ public function test_block_name_is_required(): void {
 		$block_attribute = RemoteDataBlockAttribute::from_array( $config );
 
 		$this->assertTrue( is_wp_error( $block_attribute ) );
-		$this->assertEquals( 'Object must have valid property: blockName', $block_attribute->get_error_message() );
+		$this->assertEquals( "\$config['blockName'] must be a string", $block_attribute->get_error_message() );
 	}
 
 	public function test_only_block_name_is_required(): void {
diff --git a/tests/inc/Mocks/MockValidator.php b/tests/inc/Mocks/MockValidator.php
index 846c7d9d3..06156b9c8 100644
--- a/tests/inc/Mocks/MockValidator.php
+++ b/tests/inc/Mocks/MockValidator.php
@@ -6,28 +6,16 @@
 use WP_Error;
 
 class MockValidator implements ValidatorInterface {
-	/**
-	 * @psalm-suppress UnusedProperty
-	 */
-	private array $schema;
-
-	private bool $should_pass;
-
 	/**
 	 * Constructor.
 	 *
-	 * @param array $schema      Validation schema.
-	 * @param bool  $should_pass Whether the validation should pass or fail.
+	 * @param bool $should_pass Whether the validation should pass or fail.
 	 */
-	public function __construct( array $schema = [], bool $should_pass = true ) {
-		$this->schema = $schema;
-		$this->should_pass = $should_pass;
-	}
+	public function __construct( private bool $should_pass = true ) {}
 
 	/**
 	 * Validate data against a schema.
 	 *
-	 *
 	 * @return true|\WP_Error WP_Error for invalid data, true otherwise
 	 */
 	public function validate( string|array|object|null $data ): bool|WP_Error {
@@ -41,12 +29,4 @@ public function validate( string|array|object|null $data ): bool|WP_Error {
 			[ 'status' => 400 ]
 		);
 	}
-
-	/**
-	 * Set whether the validation should pass or fail.
-	 *
-	 */
-	public function set_should_pass( bool $should_pass ): void {
-		$this->should_pass = $should_pass;
-	}
 }
diff --git a/tests/inc/Validation/ValidatorTest.php b/tests/inc/Validation/ValidatorTest.php
index 741160161..0d9bdde8e 100644
--- a/tests/inc/Validation/ValidatorTest.php
+++ b/tests/inc/Validation/ValidatorTest.php
@@ -174,11 +174,11 @@ public static function provideStrings(): array {
 	 * @dataProvider provideStrings
 	 */
 	public function testInvalidBooleans( mixed $invalid_value ): void {
-		$validator = new Validator( Types::boolean() );
+		$validator = new Validator( Types::boolean(), 'BooleanValidator', '$boolean_value' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a boolean:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$boolean_value must be a boolean', $result->get_error_message() );
 	}
 
 	/**
@@ -194,11 +194,11 @@ public function testInvalidBooleans( mixed $invalid_value ): void {
 	 * @dataProvider provideStrings
 	 */
 	public function testInvalidIntegers( mixed $invalid_value ): void {
-		$validator = new Validator( Types::integer() );
+		$validator = new Validator( Types::integer(), 'IntegerValidator', '$integer_value' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a integer:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$integer_value must be a integer', $result->get_error_message() );
 	}
 
 	/**
@@ -214,11 +214,11 @@ public function testInvalidIntegers( mixed $invalid_value ): void {
 	 * @dataProvider provideStrings
 	 */
 	public function testInvalidNulls( mixed $invalid_value ): void {
-		$validator = new Validator( Types::null() );
+		$validator = new Validator( Types::null(), 'NullValidator', '$null_value' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a null:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$null_value must be a null', $result->get_error_message() );
 	}
 
 	/**
@@ -232,11 +232,11 @@ public function testInvalidNulls( mixed $invalid_value ): void {
 	 * @dataProvider provideStrings
 	 */
 	public function testInvalidNumbers( mixed $invalid_value ): void {
-		$validator = new Validator( Types::number() );
+		$validator = new Validator( Types::number(), 'NumberValidator', '$number_value' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a number:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$number_value must be a number', $result->get_error_message() );
 	}
 
 	/**
@@ -247,11 +247,11 @@ public function testInvalidNumbers( mixed $invalid_value ): void {
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidStrings( mixed $invalid_value ): void {
-		$validator = new Validator( Types::string() );
+		$validator = new Validator( Types::string(), 'StringValidator', '$string_value' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a string:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$string_value must be a string', $result->get_error_message() );
 	}
 
 	/**
@@ -263,11 +263,11 @@ public function testInvalidStrings( mixed $invalid_value ): void {
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidButtonTexts( mixed $invalid_value ): void {
-		$validator = new Validator( Types::button_text() );
+		$validator = new Validator( Types::button_text(), 'ButtonTextValidator', '$button_text' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a button_text:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$button_text must be a button_text', $result->get_error_message() );
 	}
 
 	/**
@@ -283,11 +283,11 @@ public function testInvalidButtonTexts( mixed $invalid_value ): void {
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidButtonUrls( mixed $invalid_value ): void {
-		$validator = new Validator( Types::button_url() );
+		$validator = new Validator( Types::button_url(), 'ButtonUrlValidator', '$button_url' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a button_url:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$button_url must be a button_url', $result->get_error_message() );
 	}
 
 	/**
@@ -296,11 +296,11 @@ public function testInvalidButtonUrls( mixed $invalid_value ): void {
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidCurrencyInCurrentLocales( mixed $invalid_value ): void {
-		$validator = new Validator( Types::currency_in_current_locale() );
+		$validator = new Validator( Types::currency_in_current_locale(), 'CurrencyInCurrentLocaleValidator', '$currency_in_current_locale' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a currency_in_current_locale:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$currency_in_current_locale must be a currency_in_current_locale', $result->get_error_message() );
 	}
 
 	/**
@@ -316,11 +316,11 @@ public function testInvalidCurrencyInCurrentLocales( mixed $invalid_value ): voi
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidEmailAddresses( mixed $invalid_value ): void {
-		$validator = new Validator( Types::email_address() );
+		$validator = new Validator( Types::email_address(), 'EmailAddressValidator', '$email_address' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertinstanceof( wp_error::class, $result );
-		$this->assertstringstartswith( 'Value must be a email_address:', $result->get_error_message() );
+		$this->assertstringstartswith( '$email_address must be a email_address', $result->get_error_message() );
 	}
 
 	/**
@@ -331,11 +331,11 @@ public function testInvalidEmailAddresses( mixed $invalid_value ): void {
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidHtmls( mixed $invalid_value ): void {
-		$validator = new Validator( Types::html() );
+		$validator = new Validator( Types::html(), 'HtmlValidator', '$html' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a html:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$html must be a html', $result->get_error_message() );
 	}
 
 	/**
@@ -347,22 +347,22 @@ public function testInvalidHtmls( mixed $invalid_value ): void {
 	 * @dataProvider provideObjectLikes
 	 */
 	public function testInvalidIds( mixed $invalid_value ): void {
-		$validator = new Validator( Types::id() );
+		$validator = new Validator( Types::id(), 'IdValidator', '$id' );
 
 		$result = $validator->validate( $invalid_value );
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertStringStartsWith( 'Value must be a id:', $result->get_error_message() );
+		$this->assertStringStartsWith( '$id must be a id', $result->get_error_message() );
 	}
 
 	public function testInvalidNonPrimitiveType(): void {
 		$schema = [ '@type' => 'invented' ];
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'InvalidNonPrimitiveValidator', '$invalid_non_primitive' );
 
 		$result = $validator->validate( 'hello, world!' );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Unknown type: invented', $result->get_error_message() );
+		$this->assertSame( '$invalid_non_primitive is unknown type "invented"', $result->get_error_message() );
 	}
 
 	public function testInvalidPrimitiveType(): void {
@@ -371,18 +371,18 @@ public function testInvalidPrimitiveType(): void {
 			'@type' => 'invented',
 		];
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'InvalidPrimitiveValidator', '$invalid_primitive' );
 
 		$result = $validator->validate( 'hello, world!' );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Unknown type: invented', $result->get_error_message() );
+		$this->assertSame( '$invalid_primitive is unknown type "invented"', $result->get_error_message() );
 	}
 
 	public function testCallable(): void {
 		$schema = Types::callable();
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'CallableValidator', '$callable' );
 
 		$this->assertTrue( $validator->validate( 'is_string' ) );
 		$this->assertTrue( $validator->validate( function (): string {
@@ -393,26 +393,26 @@ public function testCallable(): void {
 		$result = $validator->validate( 'foo' );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be callable: foo', $result->get_error_message() );
+		$this->assertSame( '$callable must be callable', $result->get_error_message() );
 	}
 
 	public function testConst(): void {
 		$schema = Types::const( 'foo' );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'ConstValidator', '$const' );
 
 		$this->assertTrue( $validator->validate( 'foo' ) );
 
 		$result = $validator->validate( 'bar' );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be the constant: bar', $result->get_error_message() );
+		$this->assertSame( '$const must equal the constant "foo"', $result->get_error_message() );
 	}
 
 	public function testEnum(): void {
 		$schema = Types::enum( 'foo', 'bar' );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'EnumValidator', '$enum' );
 
 		$this->assertTrue( $validator->validate( 'foo' ) );
 		$this->assertTrue( $validator->validate( 'bar' ) );
@@ -420,26 +420,26 @@ public function testEnum(): void {
 		$result = $validator->validate( 'baz' );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be one of the enumerated values: baz', $result->get_error_message() );
+		$this->assertSame( '$enum must be one of the enumerated values: foo, bar', $result->get_error_message() );
 	}
 
 	public function testInstanceOf(): void {
 		$schema = Types::instance_of( self::class );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'InstanceOfValidator', '$instance_of' );
 
 		$this->assertTrue( $validator->validate( $this ) );
 
 		$result = $validator->validate( new stdClass() );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be an instance of the specified class: {}', $result->get_error_message() );
+		$this->assertSame( '$instance_of must be an instance of class "RemoteDataBlocks\Tests\Validation\ValidatorTest"', $result->get_error_message() );
 	}
 
 	public function testOneOf(): void {
 		$schema = Types::one_of( Types::string(), Types::integer() );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'OneOfValidator', '$one_of' );
 
 		$this->assertTrue( $validator->validate( 'foo' ) );
 		$this->assertTrue( $validator->validate( 42 ) );
@@ -447,7 +447,7 @@ public function testOneOf(): void {
 		$result = $validator->validate( null );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be one of the specified types: null', $result->get_error_message() );
+		$this->assertSame( '$one_of must match one of the specified types: string, integer', $result->get_error_message() );
 	}
 
 	public function testListOfObjects(): void {
@@ -457,7 +457,7 @@ public function testListOfObjects(): void {
 			] )
 		);
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'ListOfObjectsValidator', '$list_of_objects' );
 
 		$this->assertTrue( $validator->validate( [
 			[ 'a_string' => 'foo' ],
@@ -470,18 +470,18 @@ public function testListOfObjects(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be a list of the specified type: {"a_string":42}', $result->get_error_message() );
+		$this->assertSame( "\$list_of_objects[1]['a_string'] must be a string", $result->get_error_message() );
 		$result = $validator->validate( [
 			[ 'a_string' => 'foo' ],
 			'foo',
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must be a list of the specified type: foo', $result->get_error_message() );
+		$this->assertSame( '$list_of_objects[1] must be an associative array', $result->get_error_message() );
 	}
 
 	public function testNullableString(): void {
-		$nullable_validator = new Validator( Types::nullable( Types::string() ) );
+		$nullable_validator = new Validator( Types::nullable( Types::string() ), 'NullableStringValidator', '$nullable_string' );
 
 		$this->assertTrue( $nullable_validator->validate( null ) );
 		$this->assertTrue( $nullable_validator->validate( 'foo' ) );
@@ -493,7 +493,7 @@ public function testObject(): void {
 			'maybe_a_string' => Types::nullable( Types::string() ),
 		] );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'ObjectValidator', '$object' );
 
 		$this->assertTrue( $validator->validate( [ 'a_string' => 'foo' ] ) );
 		$this->assertTrue( $validator->validate( [
@@ -504,12 +504,12 @@ public function testObject(): void {
 		$result = $validator->validate( [ 'a_string' => 42 ] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: a_string', $result->get_error_message() );
+		$this->assertSame( "\$object['a_string'] must be a string", $result->get_error_message() );
 
 		$result = $validator->validate( [] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: a_string', $result->get_error_message() );
+		$this->assertSame( "\$object['a_string'] must be a string", $result->get_error_message() );
 	}
 
 	public function testNestedObject(): void {
@@ -526,7 +526,7 @@ public function testNestedObject(): void {
 			] ),
 		] );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'NestedObjectValidator', '$nested_object' );
 
 		$this->assertTrue( $validator->validate( [
 			'nested1' => [
@@ -553,7 +553,7 @@ public function testNestedObject(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: nested1', $result->get_error_message() );
+		$this->assertSame( "\$nested_object['nested1']['nested2']['list_of_objects'][1]['a_boolean'] must be a boolean", $result->get_error_message() );
 	}
 
 	public function testRecord(): void {
@@ -562,7 +562,7 @@ public function testRecord(): void {
 			Types::integer()
 		);
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'RecordValidator', '$record' );
 
 		$this->assertTrue( $validator->validate( [ 'record_id' => 123 ] ) );
 		$this->assertTrue( $validator->validate( [
@@ -574,7 +574,7 @@ public function testRecord(): void {
 		$result = $validator->validate( [ 'record_id' => '123' ] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Record must have valid value: 123', $result->get_error_message() );
+		$this->assertSame( "\$record['record_id'] must be a integer", $result->get_error_message() );
 	}
 
 	public function testObjectRef(): void {
@@ -588,7 +588,7 @@ public function testObjectRef(): void {
 			'bar' => Types::use_ref( 'my-ref' ),
 		] );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'RefValidator', '$object_ref' );
 
 		$this->assertTrue( $validator->validate( [
 			'foo' => [ 'a_string' => 'foo' ],
@@ -601,7 +601,7 @@ public function testObjectRef(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: bar', $result->get_error_message() );
+		$this->assertSame( "\$object_ref['bar'] must be an associative array", $result->get_error_message() );
 
 		$result = $validator->validate( [
 			'foo' => [ 'a_string' => 'foo' ],
@@ -609,7 +609,7 @@ public function testObjectRef(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: bar', $result->get_error_message() );
+		$this->assertSame( "\$object_ref['bar']['a_string'] must be a string", $result->get_error_message() );
 	}
 
 	public function testSerializedConfigFor(): void {
@@ -617,7 +617,7 @@ public function testSerializedConfigFor(): void {
 			'config' => Types::serialized_config_for( MockSerializableClass::class ),
 		] );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'SerializedConfigValidator', '$serialized_config' );
 
 		$this->assertTrue( $validator->validate( [
 			'config' => [
@@ -638,24 +638,21 @@ public function testSerializedConfigFor(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: config', $result->get_error_message() );
-		$this->assertSame( 'Object must have valid property: boolean_value', $result->get_error_data()['child']->get_error_message() );
+		$this->assertSame( "\$serialized_config['config']['boolean_value'] must be a boolean", $result->get_error_message() );
 
 		$result = $validator->validate( [
 			'config' => null,
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: config', $result->get_error_message() );
-		$this->assertSame( 'Value must be an associative array: null', $result->get_error_data()['child']->get_error_message() );
+		$this->assertSame( "\$serialized_config['config'] must be an associative array", $result->get_error_message() );
 
 		$result = $validator->validate( [
 			'config' => new stdClass(),
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: config', $result->get_error_message() );
-		$this->assertSame( 'Value must be an associative array: {}', $result->get_error_data()['child']->get_error_message() );
+		$this->assertSame( "\$serialized_config['config'] must be an associative array", $result->get_error_message() );
 
 		$result = $validator->validate( [
 			'config' => [
@@ -664,8 +661,7 @@ public function testSerializedConfigFor(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: config', $result->get_error_message() );
-		$this->assertSame( 'Object must have valid property: boolean_value', $result->get_error_data()['child']->get_error_message() );
+		$this->assertSame( "\$serialized_config['config']['boolean_value'] must be a boolean", $result->get_error_message() );
 	}
 
 	public function testSerializedConfigForSubclass(): void {
@@ -673,7 +669,7 @@ public function testSerializedConfigForSubclass(): void {
 			'config' => Types::serialized_config_for( MockSerializableClass::class ),
 		] );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'SerializedConfigForSubclassValidator', '$serialized_config' );
 
 		$this->assertTrue( $validator->validate( [
 			'config' => [
@@ -695,20 +691,19 @@ public function testSerializedConfigForSubclass(): void {
 		] );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Object must have valid property: config', $result->get_error_message() );
-		$this->assertSame( 'Object must have valid property: extra_value', $result->get_error_data()['child']->get_error_message() );
+		$this->assertSame( "\$serialized_config['config']['extra_value'] must be a string", $result->get_error_message() );
 	}
 
 	public function testStringMatching(): void {
 		$schema = Types::string_matching( '/^foo$/' );
 
-		$validator = new Validator( $schema );
+		$validator = new Validator( $schema, 'StringMatchingValidator', '$string_matching' );
 
 		$this->assertTrue( $validator->validate( 'foo' ) );
 
 		$result = $validator->validate( 'bar' );
 
 		$this->assertInstanceOf( WP_Error::class, $result );
-		$this->assertSame( 'Value must match the specified regex: bar', $result->get_error_message() );
+		$this->assertSame( '$string_matching must match regex "/^foo$/"', $result->get_error_message() );
 	}
 }


Reviews:
user: login: ingeniumed
body: I really like this way of doing the logs. I wonder if we could have all the logs go to the new `Remote Data Blocks` section by default? As we add new panels in, those can get triaged into that panel. The one that I'd really like to see is the caching panel as those cache logs do take up a lot of debugging output.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T04:49:34Z
user: login: chriszarate
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T15:30:04Z
user: login: ingeniumed
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T00:26:48Z

Review comments:
diff_hunk: @@ -40,14 +33,14 @@ public function validate( mixed $data ): bool|WP_Error {
 	 * @param mixed $value The value to validate.
path: inc/Validation/Validator.php
user: login: ingeniumed
body: Should add `$path` here as a param
created_at: 2025-04-17T04:12:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Collectors;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+class QueryMonitor {
+	public static function init(): void {
+		add_filter( 'qm/collectors', [ __CLASS__, 'add_collectors' ], 90, 1 );
+		add_filter( 'qm/outputter/html', [ __CLASS__, 'add_outputters' ], 90, 1 );
+	}
+
+	public static function add_collectors( array $collectors ): array {
+		$collector_classes = [
+			'RemoteDataBlocks\Logging\QueryMonitor\RdbHttpRequestCollector',
path: inc/Logging/QueryMonitor/QueryMonitor.php
user: login: ingeniumed
body: Was this, and `RdbHttpRequestOutputHtml` supposed to be pushed up as well? I'm not able to find them in the plugin
created_at: 2025-04-17T04:17:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+<?php declare(strict_types = 1);
+
+namespace RemoteDataBlocks\Logging\QueryMonitor;
+
+use QM_Collectors;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
+
+class QueryMonitor {
+	public static function init(): void {
+		add_filter( 'qm/collectors', [ __CLASS__, 'add_collectors' ], 90, 1 );
+		add_filter( 'qm/outputter/html', [ __CLASS__, 'add_outputters' ], 90, 1 );
+	}
+
+	public static function add_collectors( array $collectors ): array {
+		$collector_classes = [
+			'RemoteDataBlocks\Logging\QueryMonitor\RdbHttpRequestCollector',
path: inc/Logging/QueryMonitor/QueryMonitor.php
user: login: chriszarate
body: Oops, yeah, not intentionally. That class will be in my next PR. No harm since there's a `class_exists` check below, but I'll fix.
created_at: 2025-04-17T15:30:04Z
author_association: MEMBER

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-16T20:28:55Z
body: Test this PR in [WordPress Playground](https://wordpress-playground.atomicsites.blog/#{"landingPage":"/wp-admin/admin.php?page=remote-data-blocks-settings","features":{"networking":true},"login":true,"preferredVersions":{"php":"8.2","wp":"latest"},"steps":[{"step":"setSiteOptions","options":{"blogname":"Remote%20Data%20Blocks%20PR#465","blogdescription":"Explore%20the%20Remote%20Data%20Blocks%20plugin%20in%20a%20WordPress%20Playground"}},{"step":"installPlugin","pluginData":{"caption":"Installing%20RDB","resource":"url","url":"https://wordpress-playground.atomicsites.blog/plugin-proxy.php?org=Automattic&repo=remote-data-blocks&workflow=Build%20Live%20Branch&artifact=remote-data-blocks-465&pr=465"},"options":{"activate":true,"targetFolderName":"remote-data-blocks"}}]}).
author_association: CONTRIBUTOR
user: login: smithjw1
created_at: 2025-04-16T20:32:38Z
body: This is awesome! I didn't know we had this logging and it would have saved me some time.

I don't see us mention query monitor in the docs. Should we add something to the Local Development doc?  And we could be me if folks want to move on to other development tasks. 
author_association: CONTRIBUTOR
user: login: chriszarate
created_at: 2025-04-16T20:40:19Z
body: > I don't see us mention query monitor in the docs.

We mention it here, but could definitely cross-link and amplify.

https://github.com/Automattic/remote-data-blocks/blob/trunk/docs/troubleshooting.md#query-monitor

We also call it out in an admin notice when we detect issues:

https://github.com/Automattic/remote-data-blocks/blob/5c1cd08c3ce8d381d19a5e74a65e4e5ef1d9da7e/inc/Editor/AdminNotices/AdminNotices.php#L65
author_association: MEMBER
user: login: smithjw1
created_at: 2025-04-16T20:48:06Z
body: > We mention it here, but could definitely cross-link and amplify.

That will teach me to leave case-sensitive search enabled.
author_association: CONTRIBUTOR
user: login: chriszarate
created_at: 2025-04-17T15:36:22Z
body: >  I wonder if we could have all the logs go to the new Remote Data Blocks section by default? As we add new panels in, those can get triaged into that panel.

Yes, that's the goal!

> The one that I'd really like to see is the caching panel as those cache logs do take up a lot of debugging output.

Up next! :) 
author_association: MEMBER