From 13be2914bf7b43ac026f5632b309eee9a8db0f0e Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:31:15 -0400 Subject: [PATCH 1/6] feat(logging): add opt-in logging of WordPress REST API requests Log REST API requests through the existing logger pipeline when the new "Log REST API Requests" setting is enabled (off by default). Entries are written to the wpgraphql_logging_rest channel, reuse the enabled, IP restriction, sampling and log response settings, and support a new "Exclude REST Routes" prefix list. --- .../Fields/Tab/BasicConfigurationTab.php | 31 ++ .../wpgraphql-logging/src/Events/Events.php | 10 + .../src/Events/RestRequestLogger.php | 284 +++++++++++++++++ .../Processors/DataSanitizationProcessor.php | 3 + .../src/Logger/Rules/ExcludeRestRouteRule.php | 52 +++ .../src/Logger/Rules/LogRestRequestsRule.php | 36 +++ plugins/wpgraphql-logging/src/Plugin.php | 3 + .../tests/wpunit/Core/PluginTest.php | 1 + .../wpunit/Events/RestRequestLoggerTest.php | 297 ++++++++++++++++++ .../Logger/Rules/ExcludeRestRouteRuleTest.php | 55 ++++ .../Logger/Rules/LogRestRequestsRuleTest.php | 40 +++ 11 files changed, 812 insertions(+) create mode 100644 plugins/wpgraphql-logging/src/Events/RestRequestLogger.php create mode 100644 plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php create mode 100644 plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php diff --git a/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php b/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php index 47b6a823b..ad850d276 100644 --- a/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php +++ b/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php @@ -59,6 +59,20 @@ class BasicConfigurationTab implements SettingsTabInterface { */ public const LOG_RESPONSE = 'log_response'; + /** + * The field ID for whether to also log WordPress REST API requests. + * + * @var string + */ + public const LOG_REST_REQUESTS = 'log_rest_requests'; + + /** + * The field ID for the exclude REST routes text input. + * + * @var string + */ + public const EXCLUDE_REST_ROUTES = 'exclude_rest_routes'; + /** * Get the fields for this tab. * @@ -134,6 +148,23 @@ public function get_fields(): array { __( 'Whether or not to log the response from the WPGraphQL query into the context object.', 'wpgraphql-logging' ), ); + $fields[ self::LOG_REST_REQUESTS ] = new CheckboxField( + self::LOG_REST_REQUESTS, + self::get_name(), + __( 'Log REST API Requests', 'wpgraphql-logging' ), + '', + __( 'Also log WordPress REST API requests. Uses the same Enabled, IP Restrictions, Data Sampling Rate and Log Response settings as GraphQL logging. Log Points and Exclude Queries do not apply.', 'wpgraphql-logging' ), + ); + + $fields[ self::EXCLUDE_REST_ROUTES ] = new TextInputField( + self::EXCLUDE_REST_ROUTES, + self::get_name(), + __( 'Exclude REST Routes', 'wpgraphql-logging' ), + '', + __( 'Comma-separated list of REST route prefixes to exclude from logging. Only used when Log REST API Requests is enabled.', 'wpgraphql-logging' ), + __( 'e.g., /wp/v2/users, /wp-site-health/v1', 'wpgraphql-logging' ) + ); + return apply_filters( 'wpgraphql_logging_basic_configuration_fields', $fields ); } diff --git a/plugins/wpgraphql-logging/src/Events/Events.php b/plugins/wpgraphql-logging/src/Events/Events.php index 99a084a33..faa5bb204 100644 --- a/plugins/wpgraphql-logging/src/Events/Events.php +++ b/plugins/wpgraphql-logging/src/Events/Events.php @@ -64,4 +64,14 @@ final class Events { * @var string */ public const REQUEST_RESULTS = 'graphql_request_results'; + + /** + * WordPress filter: rest_pre_serve_request. + * + * A WordPress REST API request is about to be served. Only used when + * REST API request logging is enabled. + * + * @var string + */ + public const REST_REQUEST_SERVED = 'rest_pre_serve_request'; } diff --git a/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php new file mode 100644 index 000000000..72916f909 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php @@ -0,0 +1,284 @@ + + */ + protected array $config; + + /** + * When dispatching of the timed request started, keyed by its object ID. + * + * Only one request is timed at a time: the top-level HTTP request is + * always dispatched first, and nested dispatches (batch sub-requests, + * rest_do_request() calls) must not grow this indefinitely. + * + * @var array + */ + protected array $start_time = []; + + /** + * The single instance of the class. + * + * @var \WPGraphQL\Logging\Events\RestRequestLogger|null + */ + private static ?RestRequestLogger $instance = null; + + /** + * RestRequestLogger constructor. + * + * @param \WPGraphQL\Logging\Logger\LoggerService $logger The logger instance. + * @param array $config The logging configuration. + */ + public function __construct( LoggerService $logger, array $config ) { + $this->logger = $logger; + $this->config = $config; + } + + /** + * Register the REST hooks when REST logging has been switched on. + * + * Nothing is hooked while the setting is off, so installs that have not + * opted in pay no cost on REST requests. + */ + public static function init(): ?RestRequestLogger { + if ( null !== self::$instance ) { + return self::$instance; + } + + $config = ConfigurationHelper::get_instance()->get_basic_config(); + if ( ! (bool) ( $config[ BasicConfigurationTab::LOG_REST_REQUESTS ] ?? false ) ) { + return null; + } + + self::$instance = new self( LoggerService::get_instance( self::CHANNEL ), $config ); + self::$instance->register_hooks(); + + return self::$instance; + } + + /** + * Register the WordPress REST API hooks. + */ + public function register_hooks(): void { + add_filter( 'rest_pre_dispatch', [ $this, 'start_timer' ], 0, 3 ); + add_filter( 'rest_pre_serve_request', [ $this, 'log_rest_request' ], 10, 3 ); + } + + /** + * Record when dispatching of a REST request started. + * + * Hooked to the `rest_pre_dispatch` filter. The filtered value is + * returned untouched. + * + * @param mixed $result Response to replace the requested version with. + * @param \WP_REST_Server|mixed $server Server instance. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return mixed The unmodified result. + */ + public function start_timer( mixed $result, mixed $server, WP_REST_Request $request ): mixed { + if ( [] === $this->start_time ) { + $this->start_time = [ spl_object_id( $request ) => microtime( true ) ]; + } + return $result; + } + + /** + * Log a REST request just before its response is sent to the client. + * + * Hooked to the `rest_pre_serve_request` filter, which only runs for + * requests served over HTTP. The filtered value is returned untouched. + * + * @param bool|mixed $served Whether the request has already been served. + * @param \WP_HTTP_Response|mixed $result Result to send to the client. + * @param \WP_REST_Request $request Request used to generate the response. + * + * @return bool|mixed The unmodified served flag. + */ + public function log_rest_request( mixed $served, mixed $result, WP_REST_Request $request ): mixed { + try { + $duration_ms = $this->stop_timer( $request ); + $route = $request->get_route(); + if ( ! $this->should_log_rest_request( $route ) ) { + return $served; + } + + $context = $this->get_context( $request, $result, $duration_ms ); + $level = Level::Info; + if ( isset( $context['errors'] ) ) { + $level = Level::Error; + } + + $payload = EventManager::transform( Events::REST_REQUEST_SERVED, [ + 'context' => $context, + 'level' => $level, + ] ); + $message = sprintf( 'WP REST %s %s', $request->get_method(), $route ); + $this->logger->log( $payload['level'], $message, $payload['context'] ); + EventManager::publish( Events::REST_REQUEST_SERVED, [ 'context' => $payload['context'] ] ); + } catch ( \Throwable $e ) { + $this->process_application_error( Events::REST_REQUEST_SERVED, $e ); + } + + return $served; + } + + /** + * Stop the dispatch timer and return the elapsed time for the request. + * + * @param \WP_REST_Request $request The REST request. + * + * @return float|null Milliseconds since dispatch started, or null if the request was not timed. + */ + protected function stop_timer( WP_REST_Request $request ): ?float { + $request_id = spl_object_id( $request ); + $duration_ms = isset( $this->start_time[ $request_id ] ) ? round( ( microtime( true ) - $this->start_time[ $request_id ] ) * 1000.0, 2 ) : null; + + $this->start_time = []; + return $duration_ms; + } + + /** + * Whether the REST request should be logged. + * + * @param string $route The REST route. + */ + protected function should_log_rest_request( string $route ): bool { + // WPGraphQL requests are logged by the GraphQL lifecycle, never here. + if ( function_exists( 'is_graphql_request' ) && is_graphql_request() ) { + return false; + } + + return $this->is_logging_enabled( $this->config, $route ); + } + + /** + * Build the log context for a REST request. + * + * @param \WP_REST_Request $request The REST request. + * @param \WP_HTTP_Response|mixed $result The response about to be served. + * @param float|null $duration_ms Milliseconds spent dispatching the request. + * + * @return array + */ + protected function get_context( WP_REST_Request $request, mixed $result, ?float $duration_ms = null ): array { + $context = [ + 'method' => $request->get_method(), + 'route' => $request->get_route(), + 'params' => $request->get_params(), + ]; + + if ( null !== $duration_ms ) { + $context['duration_ms'] = $duration_ms; + } + + if ( ! $result instanceof WP_HTTP_Response ) { + return $context; + } + + $context['status'] = $result->get_status(); + if ( $result instanceof WP_REST_Response ) { + $context['matched_route'] = $result->get_matched_route(); + } + + if ( $result->get_status() >= 400 ) { + $context['errors'] = $result->get_data(); + } + + if ( $this->should_log_response( $this->config ) ) { + $context['response'] = $result->get_data(); + } + + return $context; + } + + /** + * Get the rule manager for REST requests, initializing it if necessary. + * + * GraphQL specific rules (query required, excluded queries) are swapped + * for REST equivalents. The route is passed to rules in place of the + * GraphQL query string. + */ + protected function get_rule_manager(): RuleManager { + if ( null !== $this->rule_manager ) { + return $this->rule_manager; + } + + $default_rules = [ + new LogRestRequestsRule(), + new EnabledRule(), + new IpRestrictionsRule(), + new ExcludeRestRouteRule(), + new SamplingRateRule(), + ]; + + /** + * Filter the logging rules used for WordPress REST API requests. + * + * @param array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> $rules Array of rule objects. + */ + $rules = apply_filters( 'wpgraphql_logging_rest_rules', $default_rules ); + + $this->rule_manager = new RuleManager(); + + /** @var \WPGraphQL\Logging\Logger\Api\LoggingRuleInterface $rule */ + foreach ( $rules as $rule ) { + $this->rule_manager->add_rule( $rule ); + } + + return $this->rule_manager; + } +} diff --git a/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php b/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php index 7ec4043af..77410ba52 100644 --- a/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php +++ b/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php @@ -70,6 +70,9 @@ protected function get_recommended_rules(): array { 'variables.username' => 'anonymize', 'variables.password' => 'anonymize', 'variables.email' => 'anonymize', + 'params.username' => 'anonymize', + 'params.password' => 'anonymize', + 'params.email' => 'anonymize', ]; return apply_filters( 'wpgraphql_logging_data_sanitization_recommended_rules', $rules ); diff --git a/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php b/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php new file mode 100644 index 000000000..c16041d72 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php @@ -0,0 +1,52 @@ + $config The logging configuration. + * @param string|null $query_string The REST route, e.g. `/wp/v2/posts`. + * + * @return bool True if the rule passes (logging should continue). + */ + public function passes(array $config, ?string $query_string = null): bool { + $routes = $config[ BasicConfigurationTab::EXCLUDE_REST_ROUTES ] ?? ''; + if ( null === $query_string || ! is_string( $routes ) || '' === trim( $routes ) ) { + return true; + } + + $route = '/' . ltrim( $query_string, '/' ); + $excluded_routes = array_filter( array_map( 'trim', explode( ',', $routes ) ), static fn( string $value ): bool => '' !== $value ); + foreach ( $excluded_routes as $excluded_route ) { + if ( str_starts_with( $route, '/' . ltrim( $excluded_route, '/' ) ) ) { + return false; + } + } + + return true; + } + + /** + * Get the rule name for debugging. + */ + public function get_name(): string { + return 'exclude_rest_route_rule'; + } +} diff --git a/plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php b/plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php new file mode 100644 index 000000000..4df176377 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php @@ -0,0 +1,36 @@ + $config The logging configuration. + * @param string|null $query_string The REST route (unused). + * + * @return bool True if the rule passes (logging should continue). + */ + public function passes(array $config, ?string $query_string = null): bool { + return (bool) ( $config[ BasicConfigurationTab::LOG_REST_REQUESTS ] ?? false ); + } + + /** + * Get the rule name for debugging. + */ + public function get_name(): string { + return 'log_rest_requests_rule'; + } +} diff --git a/plugins/wpgraphql-logging/src/Plugin.php b/plugins/wpgraphql-logging/src/Plugin.php index 298642a08..e18f9bc22 100644 --- a/plugins/wpgraphql-logging/src/Plugin.php +++ b/plugins/wpgraphql-logging/src/Plugin.php @@ -13,6 +13,7 @@ use WPGraphQL\Logging\Events\EventManager; use WPGraphQL\Logging\Events\Events; use WPGraphQL\Logging\Events\QueryEventLifecycle; +use WPGraphQL\Logging\Events\RestRequestLogger; use WPGraphQL\Logging\Logger\Api\LogServiceInterface; use WPGraphQL\Logging\Logger\Scheduler\DataDeletionScheduler; use WPGraphQL\Logging\Logger\Store\LogStoreService; @@ -62,6 +63,7 @@ public static function init(): self { */ public function setup(): void { QueryEventLifecycle::init(); // Event lifecycle for capturing logs. + RestRequestLogger::init(); // REST API request logging (opt-in). DataDeletionScheduler::init(); // Data deletion scheduler. if ( is_admin() ) { @@ -150,6 +152,7 @@ public static function set_default_configuration(): void { Events::RESPONSE_HEADERS_TO_SEND, ], BasicConfigurationTab::LOG_RESPONSE => false, + BasicConfigurationTab::LOG_REST_REQUESTS => false, ], DataManagementTab::get_name() => [ DataManagementTab::DATA_DELETION_ENABLED => true, diff --git a/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php b/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php index 21ddfeaa0..f841d0a7c 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php @@ -98,6 +98,7 @@ public function test_plugin_activate() { Events::RESPONSE_HEADERS_TO_SEND, ], BasicConfigurationTab::LOG_RESPONSE => false, + BasicConfigurationTab::LOG_REST_REQUESTS => false, ], DataManagementTab::get_name() => [ DataManagementTab::DATA_DELETION_ENABLED => true, diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php new file mode 100644 index 000000000..c7cbcba08 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php @@ -0,0 +1,297 @@ +log_service = LogStoreService::get_log_service(); + $this->logger = LoggerService::get_instance( RestRequestLogger::CHANNEL ); + } + + public function tearDown(): void { + if ( null !== $this->instance ) { + remove_filter( 'rest_pre_dispatch', [ $this->instance, 'start_timer' ], 0 ); + remove_filter( 'rest_pre_serve_request', [ $this->instance, 'log_rest_request' ], 10 ); + $this->instance = null; + } + $this->log_service->delete_all_entities(); + parent::tearDown(); + } + + /** + * @param array $config + */ + protected function create_instance( array $config, ?LoggerService $logger = null ): RestRequestLogger { + $config = array_merge( + [ + BasicConfigurationTab::ENABLED => true, + BasicConfigurationTab::LOG_REST_REQUESTS => true, + BasicConfigurationTab::DATA_SAMPLING => '100', + ], + $config + ); + + $this->instance = new RestRequestLogger( $logger ?? $this->logger, $config ); + $this->instance->register_hooks(); + return $this->instance; + } + + /** + * Dispatch a REST request and fire the serve hook like WP_REST_Server::serve_request(). + * + * @param array $query_params + */ + protected function serve( string $method, string $route, array $query_params = [] ): void { + $request = new WP_REST_Request( $method, $route ); + $request->set_query_params( $query_params ); + $response = rest_do_request( $request ); + apply_filters( 'rest_pre_serve_request', false, $response, $request, rest_get_server() ); + } + + protected function flush( ?LoggerService $logger = null ): void { + foreach ( ( $logger ?? $this->logger )->get_monolog()->getHandlers() as $handler ) { + if ( $handler instanceof BufferHandler ) { + $handler->flush(); + } + } + } + + /** + * @return array<\WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity> + */ + protected function get_logs(): array { + $this->flush(); + return $this->log_service->find_entities_by_where( [] ); + } + + public function test_logs_served_rest_request(): void { + $this->create_instance( [] ); + $this->serve( 'GET', '/wp/v2/posts', [ 'per_page' => 1 ] ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + + $log = $logs[0]; + $this->assertInstanceOf( WordPressDatabaseEntity::class, $log ); + $this->assertSame( RestRequestLogger::CHANNEL, $log->get_channel() ); + $this->assertSame( Level::Info->value, $log->get_level() ); + $this->assertSame( 'WP REST GET /wp/v2/posts', $log->get_message() ); + + $context = $log->get_context(); + $this->assertSame( 'GET', $context['method'] ); + $this->assertSame( '/wp/v2/posts', $context['route'] ); + $this->assertSame( 200, $context['status'] ); + $this->assertSame( '/wp/v2/posts', $context['matched_route'] ); + $this->assertEquals( 1, $context['params']['per_page'] ); + $this->assertArrayHasKey( 'duration_ms', $context ); + $this->assertArrayNotHasKey( 'response', $context ); + $this->assertArrayNotHasKey( 'errors', $context ); + } + + public function test_internal_rest_do_request_is_not_logged(): void { + $this->create_instance( [] ); + rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/posts' ) ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_rest_logging_disabled(): void { + $this->create_instance( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_logging_disabled(): void { + $this->create_instance( [ BasicConfigurationTab::ENABLED => false ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_sampling_rate_is_zero(): void { + $this->create_instance( [ BasicConfigurationTab::DATA_SAMPLING => '0' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_ip_not_allowed(): void { + $remote_addr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $this->create_instance( [ BasicConfigurationTab::IP_RESTRICTIONS => '10.0.0.1' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + if ( null === $remote_addr ) { + unset( $_SERVER['REMOTE_ADDR'] ); + } else { + $_SERVER['REMOTE_ADDR'] = $remote_addr; + } + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_excluded_route(): void { + $this->create_instance( [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/types, /wp/v2/posts' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + $this->serve( 'GET', '/wp/v2/taxonomies' ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( '/wp/v2/taxonomies', $logs[0]->get_context()['route'] ); + } + + public function test_graphql_exclude_queries_does_not_apply_to_rest(): void { + $this->create_instance( [ BasicConfigurationTab::EXCLUDE_QUERY => 'posts' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 1, $this->get_logs() ); + } + + public function test_error_response_logged_as_error(): void { + $this->create_instance( [] ); + $this->serve( 'GET', '/wp/v2/does-not-exist' ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( Level::Error->value, $logs[0]->get_level() ); + + $context = $logs[0]->get_context(); + $this->assertSame( 404, $context['status'] ); + $this->assertSame( 'rest_no_route', $context['errors']['code'] ); + } + + public function test_logs_response_when_log_response_enabled(): void { + $this->create_instance( [ BasicConfigurationTab::LOG_RESPONSE => true ] ); + $this->serve( 'GET', '/wp/v2/types/post' ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( 'post', $logs[0]->get_context()['response']['slug'] ); + } + + public function test_sensitive_params_are_anonymized_by_recommended_rules(): void { + $processor = new DataSanitizationProcessor(); + $reflection = new \ReflectionProperty( $processor, 'config' ); + $reflection->setAccessible( true ); + $reflection->setValue( $processor, [ + DataManagementTab::DATA_SANITIZATION_ENABLED => true, + DataManagementTab::DATA_SANITIZATION_METHOD => 'recommended', + ] ); + + $logger = LoggerService::get_instance( + RestRequestLogger::CHANNEL . '_sanitization_test', + [ new BufferHandler( new WordPressDatabaseHandler() ) ], + [ $processor ] + ); + + $this->create_instance( [], $logger ); + $this->serve( 'GET', '/wp/v2/posts', [ 'password' => 'secret', 'search' => 'hello' ] ); + $this->flush( $logger ); + + $logs = $this->log_service->find_entities_by_where( [] ); + $this->assertCount( 1, $logs ); + $params = $logs[0]->get_context()['params']; + $this->assertSame( '***', $params['password'] ); + $this->assertSame( 'hello', $params['search'] ); + } + + public function test_serve_filter_value_is_returned_unchanged(): void { + $instance = $this->create_instance( [] ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $response = rest_do_request( $request ); + + $this->assertTrue( $instance->log_rest_request( true, $response, $request ) ); + $this->assertFalse( $instance->log_rest_request( false, $response, $request ) ); + $this->assertSame( 'pre', $instance->start_timer( 'pre', rest_get_server(), $request ) ); + } + + public function test_event_transform_can_modify_context(): void { + $this->create_instance( [] ); + $callback = static function ( array $payload ): array { + $payload['context']['custom'] = 'value'; + return $payload; + }; + add_filter( 'wpgraphql_logging_filter_' . Events::REST_REQUEST_SERVED, $callback ); + + $this->serve( 'GET', '/wp/v2/posts' ); + remove_filter( 'wpgraphql_logging_filter_' . Events::REST_REQUEST_SERVED, $callback ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( 'value', $logs[0]->get_context()['custom'] ); + } + + public function test_init_does_not_hook_when_setting_is_off(): void { + $this->reset_singleton(); + $this->set_basic_config( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ); + + $this->assertNull( RestRequestLogger::init() ); + } + + public function test_init_hooks_when_setting_is_on(): void { + $this->reset_singleton(); + $this->set_basic_config( [ BasicConfigurationTab::LOG_REST_REQUESTS => true ] ); + + $instance = RestRequestLogger::init(); + $this->assertInstanceOf( RestRequestLogger::class, $instance ); + $this->assertSame( $instance, RestRequestLogger::init() ); + $this->assertSame( 0, has_filter( 'rest_pre_dispatch', [ $instance, 'start_timer' ] ) ); + $this->assertSame( 10, has_filter( 'rest_pre_serve_request', [ $instance, 'log_rest_request' ] ) ); + + $this->instance = $instance; + $this->reset_singleton(); + } + + protected function reset_singleton(): void { + $property = new \ReflectionProperty( RestRequestLogger::class, 'instance' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + } + + /** + * @param array $basic_config + */ + protected function set_basic_config( array $basic_config ): void { + $helper = ConfigurationHelper::get_instance(); + update_option( $helper->get_option_key(), [ BasicConfigurationTab::get_name() => $basic_config ] ); + $helper->clear_cache(); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php new file mode 100644 index 000000000..2f889f5b3 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php @@ -0,0 +1,55 @@ +rule = new ExcludeRestRouteRule(); + } + + public function test_passes_when_no_routes_configured(): void { + $this->assertTrue( $this->rule->passes( [], '/wp/v2/posts' ) ); + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => ' ' ], '/wp/v2/posts' ) ); + } + + public function test_passes_when_route_is_null(): void { + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/posts' ], null ) ); + } + + public function test_fails_for_exact_and_prefixed_routes(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/users' ]; + + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/users' ) ); + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/users/me' ) ); + $this->assertTrue( $this->rule->passes( $config, '/wp/v2/posts' ) ); + } + + public function test_handles_whitespace_empty_entries_and_missing_slashes(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => ' , wp/v2/types , /wp-site-health/v1 ,' ]; + + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/types/post' ) ); + $this->assertFalse( $this->rule->passes( $config, 'wp-site-health/v1/tests/background-updates' ) ); + $this->assertTrue( $this->rule->passes( $config, '/wp/v2/posts' ) ); + } + + public function test_get_name(): void { + $this->assertSame( 'exclude_rest_route_rule', $this->rule->get_name() ); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php new file mode 100644 index 000000000..cf4c83f53 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php @@ -0,0 +1,40 @@ +rule = new LogRestRequestsRule(); + } + + public function test_passes_when_enabled(): void { + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::LOG_REST_REQUESTS => true ], '/wp/v2/posts' ) ); + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::LOG_REST_REQUESTS => 1 ] ) ); + } + + public function test_fails_when_disabled_or_missing(): void { + $this->assertFalse( $this->rule->passes( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ) ); + $this->assertFalse( $this->rule->passes( [] ) ); + } + + public function test_get_name(): void { + $this->assertSame( 'log_rest_requests_rule', $this->rule->get_name() ); + } +} From f38a2b6473d634443b93a6b6b4d51cf3a7ba700f Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:33:35 -0400 Subject: [PATCH 2/6] fix(logging): harden REST request logging edge cases - Accept mixed hook arguments so a third-party apply_filters() call with unexpected values cannot fatal a REST response. - Guard with is_graphql_http_request() instead of is_graphql_request(), which is also true while a REST callback runs graphql() and would have dropped those REST entries. - Skip CORS preflight (OPTIONS) requests. - Do not store error bodies twice when Log Response is enabled. - Anonymize response.password in the recommended rules so a newly created application password is not logged in plain text. --- .../src/Events/RestRequestLogger.php | 43 +++++++++++++------ .../Processors/DataSanitizationProcessor.php | 1 + .../wpunit/Events/RestRequestLoggerTest.php | 24 +++++++++++ .../DataSanizationProcessorTest.php | 38 ++++++++++++++++ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php index 72916f909..426ea02cd 100644 --- a/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php +++ b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php @@ -123,14 +123,14 @@ public function register_hooks(): void { * Hooked to the `rest_pre_dispatch` filter. The filtered value is * returned untouched. * - * @param mixed $result Response to replace the requested version with. - * @param \WP_REST_Server|mixed $server Server instance. - * @param \WP_REST_Request $request Request used to generate the response. + * @param mixed $result Response to replace the requested version with. + * @param \WP_REST_Server|mixed $server Server instance. + * @param \WP_REST_Request|mixed $request Request used to generate the response. * * @return mixed The unmodified result. */ - public function start_timer( mixed $result, mixed $server, WP_REST_Request $request ): mixed { - if ( [] === $this->start_time ) { + public function start_timer( mixed $result, mixed $server, mixed $request ): mixed { + if ( $request instanceof WP_REST_Request && [] === $this->start_time ) { $this->start_time = [ spl_object_id( $request ) => microtime( true ) ]; } return $result; @@ -144,18 +144,23 @@ public function start_timer( mixed $result, mixed $server, WP_REST_Request $requ * * @param bool|mixed $served Whether the request has already been served. * @param \WP_HTTP_Response|mixed $result Result to send to the client. - * @param \WP_REST_Request $request Request used to generate the response. + * @param \WP_REST_Request|mixed $request Request used to generate the response. * * @return bool|mixed The unmodified served flag. */ - public function log_rest_request( mixed $served, mixed $result, WP_REST_Request $request ): mixed { + public function log_rest_request( mixed $served, mixed $result, mixed $request ): mixed { + if ( ! $request instanceof WP_REST_Request ) { + return $served; + } + try { $duration_ms = $this->stop_timer( $request ); - $route = $request->get_route(); - if ( ! $this->should_log_rest_request( $route ) ) { + if ( ! $this->should_log_rest_request( $request ) ) { return $served; } + $route = $request->get_route(); + $context = $this->get_context( $request, $result, $duration_ms ); $level = Level::Info; if ( isset( $context['errors'] ) ) { @@ -194,15 +199,22 @@ protected function stop_timer( WP_REST_Request $request ): ?float { /** * Whether the REST request should be logged. * - * @param string $route The REST route. + * @param \WP_REST_Request $request The REST request. */ - protected function should_log_rest_request( string $route ): bool { - // WPGraphQL requests are logged by the GraphQL lifecycle, never here. - if ( function_exists( 'is_graphql_request' ) && is_graphql_request() ) { + protected function should_log_rest_request( WP_REST_Request $request ): bool { + // CORS preflight requests carry no payload and would double the entries for browser clients. + if ( 'OPTIONS' === $request->get_method() ) { return false; } - return $this->is_logging_enabled( $this->config, $route ); + // HTTP requests to the GraphQL endpoint are logged by the GraphQL lifecycle. This is a + // safeguard: WPGraphQL does not serve those through the REST server. is_graphql_request() + // is deliberately not used, as it is also true while a REST callback runs graphql(). + if ( function_exists( 'is_graphql_http_request' ) && is_graphql_http_request() ) { + return false; + } + + return $this->is_logging_enabled( $this->config, $request->get_route() ); } /** @@ -234,8 +246,11 @@ protected function get_context( WP_REST_Request $request, mixed $result, ?float $context['matched_route'] = $result->get_matched_route(); } + // Error bodies are small (code, message, data) and always useful, so they are logged + // regardless of the Log Response setting. Success bodies are only logged when opted in. if ( $result->get_status() >= 400 ) { $context['errors'] = $result->get_data(); + return $context; } if ( $this->should_log_response( $this->config ) ) { diff --git a/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php b/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php index 77410ba52..eefe41465 100644 --- a/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php +++ b/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php @@ -73,6 +73,7 @@ protected function get_recommended_rules(): array { 'params.username' => 'anonymize', 'params.password' => 'anonymize', 'params.email' => 'anonymize', + 'response.password' => 'anonymize', // e.g. a new REST application password. ]; return apply_filters( 'wpgraphql_logging_data_sanitization_recommended_rules', $rules ); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php index c7cbcba08..918556d81 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php @@ -198,6 +198,30 @@ public function test_error_response_logged_as_error(): void { $this->assertSame( 'rest_no_route', $context['errors']['code'] ); } + public function test_error_body_is_not_duplicated_as_response(): void { + $this->create_instance( [ BasicConfigurationTab::LOG_RESPONSE => true ] ); + $this->serve( 'GET', '/wp/v2/does-not-exist' ); + + $context = $this->get_logs()[0]->get_context(); + $this->assertArrayHasKey( 'errors', $context ); + $this->assertArrayNotHasKey( 'response', $context ); + } + + public function test_options_preflight_requests_are_not_logged(): void { + $this->create_instance( [] ); + $this->serve( 'OPTIONS', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_non_rest_request_arguments_are_ignored(): void { + $instance = $this->create_instance( [] ); + + $this->assertSame( 'served', $instance->log_rest_request( 'served', null, 'not-a-request' ) ); + $this->assertSame( 'pre', $instance->start_timer( 'pre', null, null ) ); + $this->assertCount( 0, $this->get_logs() ); + } + public function test_logs_response_when_log_response_enabled(): void { $this->create_instance( [ BasicConfigurationTab::LOG_RESPONSE => true ] ); $this->serve( 'GET', '/wp/v2/types/post' ); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php index 56ef880bd..450c2badd 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php @@ -179,4 +179,42 @@ public function test_process_record_process_custom_rules(): void 'query' => 'query { posts { id } }' ]); } + + public function test_recommended_rules_anonymize_rest_params_and_response_password(): void { + $processor = $this->create_mock_processor( + [ + DataManagementTab::DATA_SANITIZATION_ENABLED => true, + DataManagementTab::DATA_SANITIZATION_METHOD => 'recommended', + ] + ); + + $record = new LogRecord( + new DateTimeImmutable('now'), + 'wpgraphql_logging_rest', + Level::Info, + 'WP REST POST /wp/v2/users', + [ + 'params' => [ + 'username' => 'admin', + 'password' => 'secret', + 'email' => 'admin@example.com', + 'name' => 'Admin', + ], + 'response' => [ + 'name' => 'App', + 'password' => 'abcd efgh ijkl mnop', + ], + ], + [] + ); + + $context = $processor->__invoke($record)->toArray()['context']; + + $this->assertSame('***', $context['params']['username']); + $this->assertSame('***', $context['params']['password']); + $this->assertSame('***', $context['params']['email']); + $this->assertSame('Admin', $context['params']['name']); + $this->assertSame('***', $context['response']['password']); + $this->assertSame('App', $context['response']['name']); + } } From 8080feb0031df519cf7e6a3e3f594888870d6e96 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:36:18 -0400 Subject: [PATCH 3/6] test(logging): cover REST logging rules, timer and settings Fill coverage gaps for the REST logger (rule filters, Log Points not applying, timer reset and nesting, undispatched requests) and add tests for the new settings fields. Writing these exposed that route exclusions matched raw string prefixes, so /wp/v2/users also excluded /wp/v2/users-extra, and were case-sensitive while WordPress matches routes case-insensitively. Exclusions now match the route and the routes below it, ignoring case. --- .../src/Logger/Rules/ExcludeRestRouteRule.php | 29 ++++++++-- .../Fields/Tab/BasicConfigurationTabTest.php | 41 +++++++++++++ .../wpunit/Events/RestRequestLoggerTest.php | 57 +++++++++++++++++++ .../Logger/Rules/ExcludeRestRouteRuleTest.php | 13 +++++ 4 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php diff --git a/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php b/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php index c16041d72..a366e6269 100644 --- a/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php +++ b/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php @@ -10,8 +10,10 @@ /** * Rule to check if a WordPress REST API route is excluded from logging. * - * Each configured entry is treated as a route prefix, so `/wp/v2/users` - * also excludes `/wp/v2/users/1` and `/wp/v2/users/me`. + * Each configured entry matches that route and everything below it, so + * `/wp/v2/users` also excludes `/wp/v2/users/1` and `/wp/v2/users/me`, but + * not `/wp/v2/users-extra`. Matching is case-insensitive, like WordPress + * REST route matching. * * @package WPGraphQL\Logging * @@ -32,10 +34,10 @@ public function passes(array $config, ?string $query_string = null): bool { return true; } - $route = '/' . ltrim( $query_string, '/' ); + $route = $this->normalize_route( $query_string ); $excluded_routes = array_filter( array_map( 'trim', explode( ',', $routes ) ), static fn( string $value ): bool => '' !== $value ); foreach ( $excluded_routes as $excluded_route ) { - if ( str_starts_with( $route, '/' . ltrim( $excluded_route, '/' ) ) ) { + if ( $this->matches( $route, $this->normalize_route( $excluded_route ) ) ) { return false; } } @@ -49,4 +51,23 @@ public function passes(array $config, ?string $query_string = null): bool { public function get_name(): string { return 'exclude_rest_route_rule'; } + + /** + * Whether a route is the excluded route or sits below it. + * + * @param string $route The normalized request route. + * @param string $excluded_route The normalized excluded route. + */ + protected function matches( string $route, string $excluded_route ): bool { + return $route === $excluded_route || str_starts_with( $route, rtrim( $excluded_route, '/' ) . '/' ); + } + + /** + * Normalize a route to a lowercase path with one leading slash and no trailing slash. + * + * @param string $route The route to normalize. + */ + protected function normalize_route( string $route ): string { + return '/' . strtolower( trim( $route, '/' ) ); + } } diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php new file mode 100644 index 000000000..9c5b2d6ae --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php @@ -0,0 +1,41 @@ +get_fields(); + + $this->assertInstanceOf( CheckboxField::class, $fields[ BasicConfigurationTab::LOG_REST_REQUESTS ] ); + $this->assertInstanceOf( TextInputField::class, $fields[ BasicConfigurationTab::EXCLUDE_REST_ROUTES ] ); + $this->assertSame( BasicConfigurationTab::get_name(), $fields[ BasicConfigurationTab::LOG_REST_REQUESTS ]->tab ); + } + + public function test_log_rest_requests_is_off_unless_checked(): void { + $field = ( new BasicConfigurationTab() )->get_fields()[ BasicConfigurationTab::LOG_REST_REQUESTS ]; + + $this->assertFalse( $field->sanitize_field( null ) ); + $this->assertTrue( $field->sanitize_field( '1' ) ); + } + + public function test_exclude_rest_routes_is_sanitized_as_text(): void { + $field = ( new BasicConfigurationTab() )->get_fields()[ BasicConfigurationTab::EXCLUDE_REST_ROUTES ]; + + $this->assertSame( '/wp/v2/users, /wp/v2/types', $field->sanitize_field( '/wp/v2/users, /wp/v2/types' ) ); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php index 918556d81..567b8fa97 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php @@ -185,6 +185,63 @@ public function test_graphql_exclude_queries_does_not_apply_to_rest(): void { $this->assertCount( 1, $this->get_logs() ); } + public function test_log_points_setting_does_not_apply_to_rest(): void { + $this->create_instance( [ BasicConfigurationTab::EVENT_LOG_SELECTION => [] ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 1, $this->get_logs() ); + } + + public function test_rest_rules_filter_can_replace_rules(): void { + $callback = static fn(): array => []; + add_filter( 'wpgraphql_logging_rest_rules', $callback ); + $this->create_instance( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + remove_filter( 'wpgraphql_logging_rest_rules', $callback ); + + $this->assertCount( 1, $this->get_logs() ); + } + + public function test_is_enabled_filter_applies_to_rest(): void { + add_filter( 'wpgraphql_logging_is_enabled', '__return_false' ); + $this->create_instance( [] ); + $this->serve( 'GET', '/wp/v2/posts' ); + remove_filter( 'wpgraphql_logging_is_enabled', '__return_false' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_duration_is_omitted_when_request_was_not_dispatched(): void { + $instance = $this->create_instance( [] ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $instance->log_rest_request( false, rest_ensure_response( [] ), $request ); + + $context = $this->get_logs()[0]->get_context(); + $this->assertArrayNotHasKey( 'duration_ms', $context ); + $this->assertSame( 200, $context['status'] ); + } + + public function test_only_the_first_dispatched_request_is_timed(): void { + $instance = $this->create_instance( [] ); + $outer = new WP_REST_Request( 'POST', '/batch/v1' ); + $inner = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $instance->start_timer( null, rest_get_server(), $outer ); + $instance->start_timer( null, rest_get_server(), $inner ); + + $property = new \ReflectionProperty( RestRequestLogger::class, 'start_time' ); + $property->setAccessible( true ); + $this->assertSame( [ spl_object_id( $outer ) ], array_keys( $property->getValue( $instance ) ) ); + } + + public function test_timer_is_reset_when_request_is_not_logged(): void { + $instance = $this->create_instance( [ BasicConfigurationTab::DATA_SAMPLING => '0' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $property = new \ReflectionProperty( RestRequestLogger::class, 'start_time' ); + $property->setAccessible( true ); + $this->assertSame( [], $property->getValue( $instance ) ); + } + public function test_error_response_logged_as_error(): void { $this->create_instance( [] ); $this->serve( 'GET', '/wp/v2/does-not-exist' ); diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php index 2f889f5b3..f53869a21 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php @@ -49,6 +49,19 @@ public function test_handles_whitespace_empty_entries_and_missing_slashes(): voi $this->assertTrue( $this->rule->passes( $config, '/wp/v2/posts' ) ); } + public function test_does_not_match_sibling_routes_sharing_a_prefix(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/users/' ]; + + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/users' ) ); + $this->assertTrue( $this->rule->passes( $config, '/wp/v2/users-extra' ) ); + } + + public function test_matching_is_case_insensitive(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/Users' ]; + + $this->assertFalse( $this->rule->passes( $config, '/WP/V2/USERS/1' ) ); + } + public function test_get_name(): void { $this->assertSame( 'exclude_rest_route_rule', $this->rule->get_name() ); } From 0e0998b60e1ddc0631787b6de37e296b60b67bba Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:38:36 -0400 Subject: [PATCH 4/6] refactor(logging): share rule wiring, trim REST params and document REST logging - Let the REST logger override a get_logging_rules() hook in LoggingHelper instead of copying the rule manager setup. - Log only the parameters the client sent, not every endpoint default, to keep REST entries small. - Document the REST settings, what is logged, route exclusions, sanitization, filtering by channel and the extension points. --- .../how-to/log-rest-requests/index.md | 111 ++++++++++++++++++ docs/plugins/wpgraphql-logging/index.md | 25 ++-- .../src/Events/RestRequestLogger.php | 50 +++++--- .../src/Logger/LoggingHelper.php | 27 +++-- .../wpunit/Events/RestRequestLoggerTest.php | 1 + 5 files changed, 177 insertions(+), 37 deletions(-) create mode 100644 docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md diff --git a/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md b/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md new file mode 100644 index 000000000..3057624dc --- /dev/null +++ b/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md @@ -0,0 +1,111 @@ +--- +title: "How To Guide: Log WordPress REST API Requests" +description: "Turn on logging of WordPress REST API requests in the WPGraphQL Logging plugin, exclude routes, and tell REST entries apart from GraphQL entries." +--- + +The WPGraphQL Logging plugin can log WordPress REST API requests alongside GraphQL requests. REST entries go through the same Monolog pipeline (processors, handlers and data sanitization) and show up on the same admin screen. + +REST logging is off by default. Turning it on does not change how GraphQL requests are logged. + +> \[!NOTE] +> The plugin still requires WPGraphQL to be active, even if you only want to log REST requests. + +## Turn on REST logging + +1. Go to **GraphQL Logs -> Settings** and open the **Basic Configuration** tab. +2. Make sure **Enabled** is checked. +3. Check **Log REST API Requests**. +4. Optionally, list routes to skip in **Exclude REST Routes**. +5. Save the settings. + +These Basic Configuration settings also apply to REST requests: + +| Setting | Effect on REST requests | +| ------------------- | --------------------------------------------------------------------------- | +| Enabled | Master switch. REST requests are not logged when it is off. | +| IP Restrictions | Only REST requests from these IPs are logged. | +| Data Sampling Rate | Only this percentage of REST requests is logged. | +| Log Response | Adds the response body to the entry. Error bodies are always logged. | +| Exclude Queries | Does not apply. Use **Exclude REST Routes** instead. | +| Log Points | Does not apply. Each REST request produces one entry. | + +## What gets logged + +The plugin writes one entry for each REST request served over HTTP, just before the response is sent. CORS preflight (`OPTIONS`) requests are skipped. + +Requests that are not served over HTTP are not logged. That covers PHP calls to `rest_do_request()`, embedded (`_embed`) sub-requests and block editor preloads. A batch request (`/batch/v1`) is logged as one entry. + +Each entry has: + +* **Channel**: `wpgraphql_logging_rest`. GraphQL entries use `wpgraphql_logging`. +* **Message**: the method and route, for example `WP REST GET /wp/v2/posts`. The **Event** column on the All Logs screen shows this message. +* **Level**: `INFO`, or `ERROR` when the response status is 400 or higher. +* **Context**: + * `method`, `route` and `matched_route` (the registered route pattern, such as `/wp/v2/posts/(?P[\d]+)`) + * `params`: the parameters the client sent (URL, query string, form body and JSON body). Endpoint defaults are left out. + * `status`: the HTTP status code + * `duration_ms`: time from dispatch until the response is ready to send, in milliseconds + * `errors`: the error body, when the status is 400 or higher + * `response`: the response body, when **Log Response** is on and the request succeeded + +The usual `extra` data (memory usage, request headers, process ID and so on) is added by the default processors. + +## Exclude routes + +**Exclude REST Routes** takes a comma-separated list of routes. Each entry matches that route and every route below it, ignoring case and leading or trailing slashes. + +```text +/wp/v2/users, /wp-site-health/v1 +``` + +With this value, `/wp/v2/users` and `/wp/v2/users/me` are skipped, but `/wp/v2/users-extra` and `/wp/v2/posts` are still logged. + +## Sanitize sensitive data + +When **Data Sanitization** uses the **Recommended** rules, the plugin anonymizes `params.username`, `params.password`, `params.email` and `response.password` in REST entries. + +For anything else, choose **Custom** rules on the **Data Management** tab and use dot notation against the context. For example, `params.token` or `params.billing.phone`. + +## Tell REST and GraphQL entries apart + +The channel column is stored in the database and can be filtered on. To show only REST entries on the All Logs screen, add a where clause: + +```php +add_filter( 'wpgraphql_logging_logs_table_where_clauses', function ( array $where ) { + $where[] = [ + 'column' => 'channel', + 'operator' => '=', + 'value' => \WPGraphQL\Logging\Events\RestRequestLogger::CHANNEL, + ]; + return $where; +} ); +``` + +The channel is also included when you download a log as CSV. + +## Customize which REST requests are logged + +REST requests use their own rule set, filtered with `wpgraphql_logging_rest_rules`. Rules receive the REST route in place of the GraphQL query string. See [How to add or remove a rule](../logger-add-remove-rules/index.md) for how to write a rule. + +```php +add_filter( 'wpgraphql_logging_rest_rules', function ( array $rules ) { + $rules[] = new MyPlugin\Logging\Rules\OnlyCustomNamespaceRule(); + return $rules; +} ); +``` + +The `wpgraphql_logging_is_enabled` filter runs for both GraphQL and REST requests. + +## Change or listen to REST entries + +REST entries go through the event system under `Events::REST_REQUEST_SERVED`, so you can transform or subscribe to them like GraphQL events. See [How to use the pub/sub event system](../event-pub-sub/index.md). + +```php +use WPGraphQL\Logging\Events\Events; +use WPGraphQL\Logging\Plugin; + +Plugin::transform( Events::REST_REQUEST_SERVED, function ( array $payload ): array { + $payload['context']['site_region'] = 'eu-west'; + return $payload; +} ); +``` diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 0231ad998..bbb6dd9fe 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -20,6 +20,10 @@ The plugin captures key events for the WPGraphQL event lifecycle (configurable) * **Response Headers** * **Request Results** +### WordPress REST API Requests (opt-in) + +The plugin can also log WordPress REST API requests through the same pipeline, rules and admin screen. This is off by default. See [How to log WordPress REST API requests](how-to/log-rest-requests/index.md). + ### Pub/Sub Event System Built with developers and plugins in mind, the plugin allows users to listen to individual events and transform or collect data from those events. @@ -50,12 +54,13 @@ By default the following settings are pre-configured. ### Basic Configuration -| Setting | Default Value | Description | -| --------------- | ---------------------- | ---------------------------------------------------- | -| Enabled | true | Master switch to enable/disable logging | -| Exclude Queries | `__schema,GetSeedNode` | To exclude introspection and Faust Seed Node queries | -| Data Sampling | 10% | Log only 10% of the queries | -| Log Points | Selects all | Which lifecycle events to log | +| Setting | Default Value | Description | +| --------------------- | ---------------------- | ---------------------------------------------------- | +| Enabled | true | Master switch to enable/disable logging | +| Exclude Queries | `__schema,GetSeedNode` | To exclude introspection and Faust Seed Node queries | +| Data Sampling | 10% | Log only 10% of the queries | +| Log Points | Selects all | Which lifecycle events to log | +| Log REST API Requests | false | Also log WordPress REST API requests | ### Data Management @@ -75,7 +80,9 @@ By default the following settings are pre-configured. * **Exclude Queries**: A comma-separated list of GraphQL query or mutation names to be excluded from logging. * **Data Sampling Rate**: A dropdown to select the percentage of requests that will be logged. * **Log Points**: A multi-select field to choose the specific WPGraphQL lifecycle events for which data should be logged. -* **Log Response**: A toggle to determine whether the GraphQL response body should be included in the log. +* **Log Response**: A toggle to determine whether the GraphQL response body should be included in the log. Also applies to REST API responses when REST logging is enabled. +* **Log REST API Requests**: Also log WordPress REST API requests. Off by default. See [How to log WordPress REST API requests](how-to/log-rest-requests/index.md). +* **Exclude REST Routes**: A comma-separated list of REST routes to skip, such as `/wp/v2/users`. Each entry also excludes the routes below it. Only used when REST logging is enabled. ### Data Management @@ -85,7 +92,7 @@ By default the following settings are pre-configured. * **Number of Days to Retain Logs**: Number of days logged should be stored. * **Data Sanitization Enabled**: Enable data sanitization. * **Data Sanitization Method**: Choose between two sanitization methods (default is recommended) - * **Recommended Rules (Default)**: The following fields will be removed from the event data. + * **Recommended Rules (Default)**: The following fields will be removed or anonymized in the event data. * `request.app_context.viewer.data` * `request.app_context.viewer.allcaps` * `request.app_context.viewer.cap_key` @@ -93,6 +100,8 @@ By default the following settings are pre-configured. * `variables.username` * `variables.password` * `variables.email` + * `params.username`, `params.password`, `params.email` (REST API request parameters) + * `response.password` (for example, a newly created application password in a REST response) * **Custom Rules**: Define your own sanitization rules by specifying fields to anonymize, remove, or truncate. ## Project Structure diff --git a/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php index 426ea02cd..6002f03fa 100644 --- a/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php +++ b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php @@ -13,7 +13,6 @@ use WPGraphQL\Logging\Logger\Rules\ExcludeRestRouteRule; use WPGraphQL\Logging\Logger\Rules\IpRestrictionsRule; use WPGraphQL\Logging\Logger\Rules\LogRestRequestsRule; -use WPGraphQL\Logging\Logger\Rules\RuleManager; use WPGraphQL\Logging\Logger\Rules\SamplingRateRule; use WP_HTTP_Response; use WP_REST_Request; @@ -230,7 +229,7 @@ protected function get_context( WP_REST_Request $request, mixed $result, ?float $context = [ 'method' => $request->get_method(), 'route' => $request->get_route(), - 'params' => $request->get_params(), + 'params' => $this->get_sent_params( $request ), ]; if ( null !== $duration_ms ) { @@ -261,17 +260,39 @@ protected function get_context( WP_REST_Request $request, mixed $result, ?float } /** - * Get the rule manager for REST requests, initializing it if necessary. + * Get the parameters the client sent, leaving out endpoint defaults. + * + * WP_REST_Request::get_params() also returns every registered default + * (page, per_page, context, order...), which would bloat each entry. + * Values come from get_params(), so they are the sanitized values the + * endpoint received. + * + * @param \WP_REST_Request $request The REST request. + * + * @return array + */ + protected function get_sent_params( WP_REST_Request $request ): array { + $sent = array_merge( + $request->get_url_params(), + $request->get_query_params(), + $request->get_body_params(), + $request->get_json_params() ?? [] + ); + + return array_intersect_key( $request->get_params(), $sent ); + } + + /** + * Get the rules that decide whether a REST request is logged. * * GraphQL specific rules (query required, excluded queries) are swapped * for REST equivalents. The route is passed to rules in place of the - * GraphQL query string. + * GraphQL query string. Sampling runs last so the dice are only rolled + * for requests that are otherwise eligible. + * + * @return array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> */ - protected function get_rule_manager(): RuleManager { - if ( null !== $this->rule_manager ) { - return $this->rule_manager; - } - + protected function get_logging_rules(): array { $default_rules = [ new LogRestRequestsRule(), new EnabledRule(), @@ -285,15 +306,6 @@ protected function get_rule_manager(): RuleManager { * * @param array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> $rules Array of rule objects. */ - $rules = apply_filters( 'wpgraphql_logging_rest_rules', $default_rules ); - - $this->rule_manager = new RuleManager(); - - /** @var \WPGraphQL\Logging\Logger\Api\LoggingRuleInterface $rule */ - foreach ( $rules as $rule ) { - $this->rule_manager->add_rule( $rule ); - } - - return $this->rule_manager; + return apply_filters( 'wpgraphql_logging_rest_rules', $default_rules ); } } diff --git a/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php b/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php index 1c81318cc..9d7b10289 100644 --- a/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php +++ b/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php @@ -78,6 +78,22 @@ protected function get_rule_manager(): RuleManager { return $this->rule_manager; } + $this->rule_manager = new RuleManager(); + + /** @var \WPGraphQL\Logging\Logger\Api\LoggingRuleInterface $rule */ + foreach ( $this->get_logging_rules() as $rule ) { + $this->rule_manager->add_rule( $rule ); + } + + return $this->rule_manager; + } + + /** + * Get the rules that decide whether a GraphQL event is logged. + * + * @return array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> + */ + protected function get_logging_rules(): array { $default_rules = [ new QueryNullRule(), new SamplingRateRule(), @@ -91,16 +107,7 @@ protected function get_rule_manager(): RuleManager { * * @param array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> $rules Array of rule objects. */ - $rules = apply_filters( 'wpgraphql_logging_rules', $default_rules ); - - $this->rule_manager = new RuleManager(); - - /** @var \WPGraphQL\Logging\Logger\Api\LoggingRuleInterface $rule */ - foreach ( $rules as $rule ) { - $this->rule_manager->add_rule( $rule ); - } - - return $this->rule_manager; + return apply_filters( 'wpgraphql_logging_rules', $default_rules ); } /** diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php index 567b8fa97..fea2653ff 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php @@ -120,6 +120,7 @@ public function test_logs_served_rest_request(): void { $this->assertSame( 200, $context['status'] ); $this->assertSame( '/wp/v2/posts', $context['matched_route'] ); $this->assertEquals( 1, $context['params']['per_page'] ); + $this->assertArrayNotHasKey( 'order', $context['params'], 'Endpoint defaults should not be logged.' ); $this->assertArrayHasKey( 'duration_ms', $context ); $this->assertArrayNotHasKey( 'response', $context ); $this->assertArrayNotHasKey( 'errors', $context ); From d23cda7740f0076b1b0eb5ec227f60870b2716ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 30 Oct 2026 19:39:02 +0000 Subject: [PATCH 5/6] chore: regenerate llms.txt [skip ci] --- llms.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/llms.txt b/llms.txt index d28d23a2f..1ce27b364 100644 --- a/llms.txt +++ b/llms.txt @@ -54,6 +54,7 @@ Documentation for the Headless WordPress Toolkit, a modern, framework-agnostic c - [How To Guide: Update the Admin Grid](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/admin-add-view-column/index.md): Learn how to add custom columns to the WPGraphQL Logging plugin admin logs table. - [How To Guide: Add Data to an Event](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/event-add-context/index.md): Learn how to add custom context data to WPGraphQL Logging event. - [How To Guide: Use the Events Pub/Sub System](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/event-pub-sub/index.md): Learn how to use the WPGraphQL Logging plugin's event pub/sub system to subscribe, transform, and emit events. + - [How To Guide: Log WordPress REST API Requests](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md): Turn on logging of WordPress REST API requests in the WPGraphQL Logging plugin, exclude routes, and tell REST entries apart from GraphQL entries. - [How To Guide: Add a new Handler](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/logger-add-handler/index.md): This guide shows how to log to a file using a Monolog handler, either in addition to the default WordPress database handler or as a replacement. It also covers per-instance overrides. - [How To Guide: Add a new Processor](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/logger-add-processor/index.md): Learn how to add custom Monolog processors to the WPGraphQL Logging plugin to transform log records. - [How To Guide: Add or Remove a Rule](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/logger-add-remove-rules/index.md): Learn how to create and register custom logging rules with the WPGraphQL Logging plugin. From 356c126b5355e0aa29721d4b6eeb8a04fad50e41 Mon Sep 17 00:00:00 2001 From: Joe Fusco Date: Fri, 30 Oct 2026 15:47:34 -0400 Subject: [PATCH 6/6] test(logging): flush buffered REST log records between tests The REST channel logger is a shared singleton behind a BufferHandler. test_serve_filter_value_is_returned_unchanged wrote two records without reading them, so they stayed buffered past tearDown's delete and were flushed into the next test, which then saw three entries instead of one. Flush the buffer in tearDown before deleting entries. --- .../tests/wpunit/Events/RestRequestLoggerTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php index fea2653ff..521060518 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php @@ -51,6 +51,9 @@ public function tearDown(): void { remove_filter( 'rest_pre_serve_request', [ $this->instance, 'log_rest_request' ], 10 ); $this->instance = null; } + // The channel logger is a shared singleton with a BufferHandler. Flush records a test + // wrote but never read, so they are deleted here instead of leaking into the next test. + $this->flush(); $this->log_service->delete_all_entities(); parent::tearDown(); }