diff --git a/plugins/wpgraphql-logging/README.md b/plugins/wpgraphql-logging/README.md index 65cefa3c..84484323 100644 --- a/plugins/wpgraphql-logging/README.md +++ b/plugins/wpgraphql-logging/README.md @@ -6,11 +6,11 @@ A WPGraphQL logging plugin that provides visibility into request lifecycle to he * [Documentation](#getting-started) > [!CAUTION] -> This plugin is currently in development state and is not production ready. +> This plugin is currently in alpha state and is not production ready but please feel free to test. ----- -@TODO - Badges +@TODO ----- @@ -19,8 +19,9 @@ A WPGraphQL logging plugin that provides visibility into request lifecycle to he ## Table of Contents - [Overview](#overview) -- [Features](#features) - [Getting Started](#getting-started) +- [Features](#features) +- [Usage](#usage) - [Configuration](#configuration) - [Extending the Functionality](#extending-the-functionality) - [Testing](#testing) @@ -37,14 +38,16 @@ Designed with extensibility in mind, developers can easily customize and extend --- -## Features -@TODO +## Getting Started +To install, you need to follow our guide here to install the plugin via composer - [https://github.com/wpengine/hwptoolkit/blob/main/docs/how-to/install-toolkit-plugins/index.md] -## Getting Started +Once you have the composer repository setup, please run `composer req wpengine/wpgraphql-logging:*` to install the plugin. -@TODO +Plugin should start logging data, once activated. + +@TODO add more info once we have configuration setup. --- @@ -53,10 +56,10 @@ Designed with extensibility in mind, developers can easily customize and extend ```text wpgraphql-logging/ +├── docs/ # Docs for extending the plugin. ├── src/ # Main plugin source code │ ├── Admin/ # Admin settings, menu, and settings page logic -│ ├── Events/ # Event definitions and event dispatcher logic -│ ├── Hooks/ # WordPress hooks and filters +│ ├── Events/ # Event logging, pub/sub event manager for extending the logging. │ ├── Logging/ # Logging logic, logger service, Monolog handlers & processors │ ├── Plugin.php # Main plugin class (entry point) │ └── Autoload.php # PSR-4 autoloader @@ -66,24 +69,60 @@ wpgraphql-logging/ ├── [activation.php] ├── [composer.json] ├── [deactivation.php] -├── [ACTIONS_AND_FILTERS.md] ├── [TESTING.md] ├── [README.md] ``` -## Configuration +## Features -@TODO - When we integrate plugin configuration. +- **Query event lifecycle logging** + - **Pre Request** (`do_graphql_request`): captures `query`, `variables`, `operation_name`. + - **Before Execution** (`graphql_before_execute`): includes a snapshot of request `params`. + - **Before Response Returned** (`graphql_return_response`): inspects `response` and automatically upgrades level to Error when GraphQL `errors` are present (adds `errors` to context when found). -### Settings +- **Built-in pub/sub event bus** + - In-memory event manager with priorities: `subscribe(event, listener, priority)` and `publish(event, payload)`. + - Transform pipeline: `transform(event, payload)` lets you mutate `context` and `level` before logging/publishing. + - WordPress bridges: actions `wpgraphql_logging_event_{event}` and filters `wpgraphql_logging_filter_{event}` to integrate with standard hooks. + +- **Monolog-powered logging pipeline** + - Default handler: stores logs in a WordPress table (`{$wpdb->prefix}wpgraphql_logging`). + - Default processors: Memory usage, memory peak, web request, process ID, and `WPGraphQLQueryProcessor` (adds `wpgraphql_query`, `wpgraphql_operation_name`, `wpgraphql_variables`). + +- **Simple developer API** + - `Plugin::on()` to subscribe, `Plugin::emit()` to publish, `Plugin::transform()` to modify payloads. + +- **Safe-by-default listeners/transforms** + - Exceptions in listeners/transforms are caught and logged without breaking the pipeline. + +--- + +## Usage + +WPGraphQL Logging Plugin is highly configurable and extendable and built with developers in mind to allow them to modify, change or add data, loggers etc to this plugin. Please read the docs below: + + +The following documentation is available in the `docs/` directory: + +- [Events](docs/Events.md): + Learn about the event system, available events, and how to subscribe, transform, or listen to WPGraphQL Logging events. + +- [Logging](docs/Logging.md): + Learn about the logging system, Monolog integration, handlers, processors, and how to use or extend the logger. + +--- + + + +## Configuration @TODO - When we integrate plugin configuration. --- -## Actions & Filters +### Settings -See the [Actions & Filters documentation](ACTIONS_AND_FILTERS.md) for a comprehensive list of available hooks and how to use them. +@TODO - When we integrate plugin configuration. --- diff --git a/plugins/wpgraphql-logging/composer.json b/plugins/wpgraphql-logging/composer.json index 57469607..801da78f 100644 --- a/plugins/wpgraphql-logging/composer.json +++ b/plugins/wpgraphql-logging/composer.json @@ -53,6 +53,7 @@ "slevomat/coding-standard": "^8.0", "szepeviktor/phpstan-wordpress": "^2.0", "wp-cli/wp-cli-bundle": "^2.8.1", + "wp-graphql/wp-graphql": "^2.3", "wp-graphql/wp-graphql-testcase": "^3.0.1" }, "config": { @@ -80,7 +81,7 @@ "/.DS_Store", "/.docker/", "/.env.dist", - "/ACTIONS_AND_FILTERS.md", + "/docs", "/TESTING.md", "/Thumbs.db", "/artifacts", diff --git a/plugins/wpgraphql-logging/composer.lock b/plugins/wpgraphql-logging/composer.lock index 14ff86bb..d943791d 100644 --- a/plugins/wpgraphql-logging/composer.lock +++ b/plugins/wpgraphql-logging/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "247dce5261ceda43f94d7a8ce68deafd", + "content-hash": "964e2ef59c5c3fb7f03c0439c6c94ae3", "packages": [ { "name": "monolog/monolog", @@ -321,6 +321,60 @@ ], "time": "2024-04-13T18:00:56+00:00" }, + { + "name": "appsero/client", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Appsero/client.git", + "reference": "12ff65b9770286d21edf314e7acfcd26fdde3315" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Appsero/client/zipball/12ff65b9770286d21edf314e7acfcd26fdde3315", + "reference": "12ff65b9770286d21edf314e7acfcd26fdde3315", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.7.2", + "phpcompatibility/phpcompatibility-wp": "dev-master", + "phpunit/phpunit": "^8.5.31", + "squizlabs/php_codesniffer": "^3.7", + "tareq1988/wp-php-cs-fixer": "dev-master", + "wp-coding-standards/wpcs": "dev-develop" + }, + "type": "library", + "autoload": { + "psr-4": { + "Appsero\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tareq Hasan", + "email": "tareq@appsero.com" + } + ], + "description": "Appsero Client", + "keywords": [ + "analytics", + "plugin", + "theme", + "wordpress" + ], + "support": { + "issues": "https://github.com/Appsero/client/issues", + "source": "https://github.com/Appsero/client/tree/v2.0.4" + }, + "time": "2024-11-25T05:58:23+00:00" + }, { "name": "automattic/vipwpcs", "version": "3.0.1", @@ -3018,6 +3072,51 @@ }, "time": "2024-04-01T10:36:11+00:00" }, + { + "name": "ivome/graphql-relay-php", + "version": "v0.7.0", + "source": { + "type": "git", + "url": "https://github.com/ivome/graphql-relay-php.git", + "reference": "06bd176103618d896197d85d04a3a17c91e39698" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ivome/graphql-relay-php/zipball/06bd176103618d896197d85d04a3a17c91e39698", + "reference": "06bd176103618d896197d85d04a3a17c91e39698", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "webonyx/graphql-php": "^14.0 || ^15.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "satooshi/php-coveralls": "~1.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP port of GraphQL Relay reference implementation", + "homepage": "https://github.com/ivome/graphql-relay-php", + "keywords": [ + "Relay", + "api", + "graphql" + ], + "support": { + "issues": "https://github.com/ivome/graphql-relay-php/issues", + "source": "https://github.com/ivome/graphql-relay-php/tree/v0.7.0" + }, + "time": "2023-10-20T15:43:03+00:00" + }, { "name": "johnpbloch/wordpress-core", "version": "6.8.2", @@ -9131,6 +9230,80 @@ }, "time": "2022-06-03T18:03:27+00:00" }, + { + "name": "webonyx/graphql-php", + "version": "v15.20.0", + "source": { + "type": "git", + "url": "https://github.com/webonyx/graphql-php.git", + "reference": "60feb7ad5023c0ef411efbdf9792d3df5812e28f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/60feb7ad5023c0ef411efbdf9792d3df5812e28f", + "reference": "60feb7ad5023c0ef411efbdf9792d3df5812e28f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "php": "^7.4 || ^8" + }, + "require-dev": { + "amphp/amp": "^2.6", + "amphp/http-server": "^2.1", + "dms/phpunit-arraysubset-asserts": "dev-master", + "ergebnis/composer-normalize": "^2.28", + "friendsofphp/php-cs-fixer": "3.73.1", + "mll-lab/php-cs-fixer-config": "5.11.0", + "nyholm/psr7": "^1.5", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "2.1.8", + "phpstan/phpstan-phpunit": "2.0.4", + "phpstan/phpstan-strict-rules": "2.0.4", + "phpunit/phpunit": "^9.5 || ^10.5.21 || ^11", + "psr/http-message": "^1 || ^2", + "react/http": "^1.6", + "react/promise": "^2.0 || ^3.0", + "rector/rector": "^2.0", + "symfony/polyfill-php81": "^1.23", + "symfony/var-exporter": "^5 || ^6 || ^7", + "thecodingmachine/safe": "^1.3 || ^2 || ^3" + }, + "suggest": { + "amphp/http-server": "To leverage async resolving with webserver on AMPHP platform", + "psr/http-message": "To use standard GraphQL server", + "react/promise": "To leverage async resolving on React PHP platform" + }, + "type": "library", + "autoload": { + "psr-4": { + "GraphQL\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP port of GraphQL reference implementation", + "homepage": "https://github.com/webonyx/graphql-php", + "keywords": [ + "api", + "graphql" + ], + "support": { + "issues": "https://github.com/webonyx/graphql-php/issues", + "source": "https://github.com/webonyx/graphql-php/tree/v15.20.0" + }, + "funding": [ + { + "url": "https://opencollective.com/webonyx-graphql-php", + "type": "open_collective" + } + ], + "time": "2025-03-21T08:45:04+00:00" + }, { "name": "wp-cli/cache-command", "version": "v2.2.0", @@ -11447,6 +11620,89 @@ ], "time": "2024-03-25T16:39:00+00:00" }, + { + "name": "wp-graphql/wp-graphql", + "version": "v2.3.3", + "source": { + "type": "git", + "url": "https://github.com/wp-graphql/wp-graphql.git", + "reference": "0b1512b746818bb3a0b0347f0a6841c811426f0f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wp-graphql/wp-graphql/zipball/0b1512b746818bb3a0b0347f0a6841c811426f0f", + "reference": "0b1512b746818bb3a0b0347f0a6841c811426f0f", + "shasum": "" + }, + "require": { + "appsero/client": "2.0.4", + "ivome/graphql-relay-php": "0.7.0", + "php": "^7.4 || ^8.0", + "webonyx/graphql-php": "15.20.0" + }, + "require-dev": { + "automattic/vipwpcs": "^3.0", + "codeception/module-asserts": "^1.0", + "codeception/module-cli": "^1.0", + "codeception/module-db": "^1.0", + "codeception/module-filesystem": "^1.0", + "codeception/module-phpbrowser": "^1.0", + "codeception/module-rest": "^1.2", + "codeception/module-webdriver": "^1.0", + "codeception/util-universalframework": "^1.0", + "composer/semver": "^3.0", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "lucatume/wp-browser": "<3.5", + "phpcompatibility/php-compatibility": "dev-develop as 9.9.9", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "~2.1.2", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^9.5", + "slevomat/coding-standard": "^8.9", + "szepeviktor/phpstan-wordpress": "~2.0.1", + "wp-cli/wp-cli-bundle": "^2.8", + "wp-graphql/wp-graphql-testcase": "^3.0" + }, + "type": "wordpress-plugin", + "autoload": { + "files": [], + "psr-4": { + "WPGraphQL\\": "src/" + }, + "classmap": [ + "src/WPGraphQL.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0-or-later" + ], + "authors": [ + { + "name": "Jason Bahl", + "email": "jasonbahl@mac.com" + }, + { + "name": "Edwin Cromley" + }, + { + "name": "Ryan Kanner" + }, + { + "name": "Hughie Devore" + }, + { + "name": "Chris Zarate" + } + ], + "description": "GraphQL API for WordPress", + "support": { + "issues": "https://github.com/wp-graphql/wp-graphql/issues", + "source": "https://github.com/wp-graphql/wp-graphql/tree/v2.3.3" + }, + "time": "2025-06-17T14:27:16+00:00" + }, { "name": "wp-graphql/wp-graphql-testcase", "version": "v3.4.0", diff --git a/plugins/wpgraphql-logging/docs/Events.md b/plugins/wpgraphql-logging/docs/Events.md new file mode 100644 index 00000000..f6d05387 --- /dev/null +++ b/plugins/wpgraphql-logging/docs/Events.md @@ -0,0 +1,132 @@ +# Events in WPGraphQL Logging + +## Table of Contents + +- [Overview](#overview) +- [Available Events](#available-events) +- [Usage](#usage) + - [Example 1: How to subscribe to an event](#example-1-how-to-subscribe-to-an-event) + - [Example 2: How to add context to an event](#example-2-how-to-add-context-to-an-event) + - [Example 3: How to run a WPGraphQL event](#example-3-how-to-run-a-wpgraphql-event) + - [Example 4: Use WordPress hooks](#example-4-use-wordpress-hooks) + +## Overview + +WPGraphQL Logging implements a pub/sub pattern for events to subscribe to certain WPGraphQL events and allows users to subscribe/publish or transform events. + +This is achieved in the following classes under `src/Events/`: + +- **Events** - List of events the plugin hooks into for WPGraphQL +- **EventManager** - An event manager which creates a pub/sub pattern to allow users to subscribe/publish events and also transform context or level for the current event +- **QueryEventLifecycle** - The service that puts this all together and creates the logic and logs the data into the LoggerService (Monolog logger) + +> **Note**: If we are missing anything from this event system, please feel free to create an issue or contribute. + +## Available Events + +Currently we subscribe to the following WPGraphQL events: + +| Event Constant | WPGraphQL Hook | Description | +| --- | --- | --- | +| `Events::PRE_REQUEST` | `do_graphql_request` | Before the request is processed | +| `Events::BEFORE_GRAPHQL_EXECUTION` | `graphql_before_execute` | Before query execution | +| `Events::BEFORE_RESPONSE_RETURNED` | `graphql_return_response` | Before response is returned to client | + +## Usage + +### Example 1: How to subscribe to an event + +**Use case** You would like to access data for a specific event. + +**Example** + + +```php +info('Resolving field', [ + 'field' => $info->fieldName ?? '', + 'type' => method_exists($info, 'parentType') ? (string) $info->parentType : '', + ]); +}, 10, 4); + +``` + +### Example 4. Use WordPress hooks + +In addition to the internal API, every event also triggers standard WordPress hooks: + +- Action: `wpgraphql_logging_event_{event_name}` receives the published payload +- Filter: `wpgraphql_logging_filter_{event_name}` can modify the payload before logging + + ```php + **Note**: This system leverages the full power of Monolog. For advanced usage, refer to the [Monolog documentation](https://github.com/Seldaek/monolog/blob/main/doc/01-usage.md). + +## Architecture + +### Channels +Channels group log messages by context or component. The default channel is `wpgraphql_logging`, but you can create multiple logger instances with different channels for different parts of your application. + +### Handlers +Handlers determine where log records are written. The plugin includes a simple database handler. +- **WordPressDatabaseHandler** - Writes logs to a WordPress database table (`{$wpdb->prefix}wpgraphql_logging`) + +### Processors +Processors add extra data to each log record. The plugin includes several default processors: + +- **MemoryUsageProcessor** - Adds current memory usage +- **MemoryPeakUsageProcessor** - Adds peak memory usage +- **WebProcessor** - Adds web request data (IP, method, URI, etc.) +- **ProcessIdProcessor** - Adds the process ID +- **WPGraphQLQueryProcessor** - Adds GraphQL query, variables, and operation name + +## Default Components + +The LoggerService comes configured with sensible defaults: + +| Component | Default Implementation | Purpose | +| --- | --- | --- | +| Handler | `WordPressDatabaseHandler` | Stores logs in WordPress database | +| Processors | Memory, Web, Process ID, WPGraphQL Query | Enriches log records with contextual data | +| Log Levels | All levels (DEBUG to EMERGENCY) | Monolog's standard PSR-3 log levels | +| Default Context | WP version, plugin version, debug mode, site URL | Consistent context across all logs | + +## Usage + +### Example 1: Basic logging with LoggerService + +**Use case:** You want to log custom events from your plugin or theme. + +```php +info('User performed action', ['user_id' => 123, 'action' => 'login']); +$logger->warning('Rate limit approaching', ['requests' => 95, 'limit' => 100]); +$logger->error('Database connection failed', ['error' => $exception->getMessage()]); + +// Use the generic log method with Monolog levels +$logger->log(Level::Debug, 'Debug information', ['debug_data' => $debug_info]); +``` + +### Example 2: Creating a custom logger instance + +**Use case:** You need a separate logger with a different channel for a specific component. + +```php + 'my_plugin'] // Custom default context +); + +$custom_logger->info('Custom component event', ['data' => 'example']); +``` + +### Example 3: Adding custom handlers + +**Use case:** You want to log to multiple destinations (database + file + external service). + +```php +error('Critical error occurred', ['error' => 'Something went wrong']); +``` + +### Example 4: Adding custom processors + +**Use case:** You want to add custom data to all log records, such as user information or custom metrics. + +```php +extra['user_id'] = get_current_user_id(); + $record->extra['user_role'] = wp_get_current_user()->roles[0] ?? 'unknown'; + } + return $record; + } +} + +// Add custom processors +$processors = array_merge( + LoggerService::get_default_processors(), + [new UserContextProcessor()] +); + +$logger = LoggerService::get_instance('user_aware', null, $processors); +$logger->info('User action logged'); // Will include user_id and user_role in extra data +``` + +### Example 5: Customizing log levels and filtering + +**Use case:** You want to control which log levels are actually processed, or apply filtering based on content. + +```php +debug('Debug message'); // Won't be logged +$logger->info('Info message'); // Won't be logged +$logger->warning('Warning message'); // Will be logged +$logger->error('Error message'); // Will be logged +``` + +### Example 6: Using WordPress filters to modify defaults + +**Use case:** You want to globally modify the default handlers, processors, or context for all LoggerService instances. + +```php +info('This will include the custom context and use all handlers'); +``` + +## Available Log Levels + +WPGraphQL Logging supports all standard [PSR-3 log levels](https://www.php-fig.org/psr/psr-3/) via Monolog: + +| Level | Method | Use Case | +| --- | --- | --- | +| `EMERGENCY` | `$logger->emergency()` | System is unusable | +| `ALERT` | `$logger->alert()` | Action must be taken immediately | +| `CRITICAL` | `$logger->critical()` | Critical conditions | +| `ERROR` | `$logger->error()` | Error conditions | +| `WARNING` | `$logger->warning()` | Warning conditions | +| `NOTICE` | `$logger->notice()` | Normal but significant condition | +| `INFO` | `$logger->info()` | Informational messages | +| `DEBUG` | `$logger->debug()` | Debug-level messages | + +You can also use the generic `$logger->log($level, $message, $context)` method with `Monolog\Level` constants. + +## WordPress Filters + +The following WordPress filters are available to customize the logging system: + +- `wpgraphql_logging_default_handlers` - Modify default handlers +- `wpgraphql_logging_default_processors` - Modify default processors +- `wpgraphql_logging_default_context` - Modify default context +- `wpgraphql_logging_database_name` - Customize the database table name + +## Further Reading + +- [Monolog Documentation](https://github.com/Seldaek/monolog/blob/main/doc/01-usage.md) +- [PSR-3 Logger Interface](https://www.php-fig.org/psr/psr-3/) +- [Monolog Handlers](https://github.com/Seldaek/monolog/blob/main/doc/02-handlers-formatters-processors.md#handlers) +- [Monolog Processors](https://github.com/Seldaek/monolog/blob/main/doc/02-handlers-formatters-processors.md#processors) diff --git a/plugins/wpgraphql-logging/phpcs.xml b/plugins/wpgraphql-logging/phpcs.xml index 87378803..c5ad6dde 100644 --- a/plugins/wpgraphql-logging/phpcs.xml +++ b/plugins/wpgraphql-logging/phpcs.xml @@ -14,8 +14,7 @@ */languages/* */phpunit.xml* **/tests/** - */vendor/* - */src/Events/QueryEventLifecycle.php + */vendor/*` diff --git a/plugins/wpgraphql-logging/phpstan.neon.dist b/plugins/wpgraphql-logging/phpstan.neon.dist index e6722105..05f71e10 100644 --- a/plugins/wpgraphql-logging/phpstan.neon.dist +++ b/plugins/wpgraphql-logging/phpstan.neon.dist @@ -30,5 +30,3 @@ parameters: paths: - wpgraphql-logging.php - src/ - excludePaths: - - src/Events/QueryEventLifecycle.php diff --git a/plugins/wpgraphql-logging/psalm.xml b/plugins/wpgraphql-logging/psalm.xml index 552f0d26..2b45895f 100644 --- a/plugins/wpgraphql-logging/psalm.xml +++ b/plugins/wpgraphql-logging/psalm.xml @@ -15,9 +15,6 @@ - - - diff --git a/plugins/wpgraphql-logging/readme.txt b/plugins/wpgraphql-logging/readme.txt index f3d015bc..2ddc4b37 100644 --- a/plugins/wpgraphql-logging/readme.txt +++ b/plugins/wpgraphql-logging/readme.txt @@ -13,7 +13,22 @@ License URI: https://www.gnu.org/licenses/gpl-2.0.html A WPGraphQL logging plugin that provides visibility into request lifecycle to help quickly identify and resolve bottlenecks in your headless WordPress application. -@TODO += Features = + +- Query event lifecycle logging: + - Pre Request (`do_graphql_request`): logs query, variables, operation name + - Before Execution (`graphql_before_execute`): logs request params snapshot + - After Execution (`graphql_execute`): logs response, schema, request + - Before Response Returned (`graphql_return_response`): inspects response errors and upgrades level to Error when present + +- Pub/sub system: + - Subscribe with priorities, publish events, and apply transforms to mutate payloads + - WordPress bridges: `wpgraphql_logging_event_{event}` (action) and `wpgraphql_logging_filter_{event}` (filter) + +- Monolog-based storage and context: + - Default handler writes to a WordPress table (`{$wpdb->prefix}wpgraphql_logging`) + - Processors include memory usage, web request, process ID, and GraphQL request details + == Upgrade Notice == == Frequently Asked Questions == == Screenshots == diff --git a/plugins/wpgraphql-logging/src/Events/EventManager.php b/plugins/wpgraphql-logging/src/Events/EventManager.php new file mode 100644 index 00000000..42d0a11b --- /dev/null +++ b/plugins/wpgraphql-logging/src/Events/EventManager.php @@ -0,0 +1,202 @@ +>> + */ + private static array $events = []; + + /** + * Transform listeners that can modify a payload. + * + * @var array>> + */ + private static array $transforms = []; + + /** + * Subscribe a listener to an event. + * + * @param string $event_name Event name (see Events constants). + * @param callable $listener Listener callable: function(array $payload): void {}. + * @param int $priority Lower runs earlier. + */ + public static function subscribe(string $event_name, callable $listener, int $priority = 10): void { + if ( ! isset( self::$events[ $event_name ] ) ) { + self::$events[ $event_name ] = []; + } + if ( ! isset( self::$events[ $event_name ][ $priority ] ) ) { + self::$events[ $event_name ][ $priority ] = []; + } + + self::$events[ $event_name ][ $priority ][] = $listener; + } + + /** + * Publish an event to all subscribers and a WordPress action bridge. + * + * @param string $event_name Event name (see Events constants). + * @param array $payload Arbitrary payload for listeners. + */ + public static function publish(string $event_name, array $payload = []): void { + + $ordered_listeners = self::get_ordered_listeners( $event_name ); + + if ( [] === $ordered_listeners ) { + /** @psalm-suppress HookNotFound */ + do_action( 'wpgraphql_logging_event_' . $event_name, $payload ); + return; + } + + foreach ( $ordered_listeners as $listener ) { + self::invoke_listener( $listener, $payload ); + } + + /** @psalm-suppress HookNotFound */ + do_action( 'wpgraphql_logging_event_' . $event_name, $payload ); + } + + /** + * Subscribe a transformer to modify the payload before it is used by core code. + * + * @param string $event_name Event name. + * @param callable $transform function(array $payload): array {}. + * @param int $priority Lower runs earlier. + */ + public static function subscribe_to_transform(string $event_name, callable $transform, int $priority = 10): void { + if ( ! isset( self::$transforms[ $event_name ] ) ) { + self::$transforms[ $event_name ] = []; + } + if ( ! isset( self::$transforms[ $event_name ][ $priority ] ) ) { + self::$transforms[ $event_name ][ $priority ] = []; + } + + self::$transforms[ $event_name ][ $priority ][] = $transform; + } + + /** + * Transform a payload by running transform subscribers and a WordPress filter bridge. + * + * @param string $event_name Event name. + * @param array $payload Initial payload. + * + * @return array Modified payload. + */ + public static function transform(string $event_name, array $payload): array { + + $ordered_transforms = self::get_ordered_transforms( $event_name ); + if ( [] === $ordered_transforms ) { + /** @psalm-suppress HookNotFound */ + return apply_filters( 'wpgraphql_logging_filter_' . $event_name, $payload ); + } + + foreach ( $ordered_transforms as $transform ) { + $payload = self::invoke_transform( $transform, $payload ); + } + + /** @psalm-suppress HookNotFound */ + return apply_filters( 'wpgraphql_logging_filter_' . $event_name, $payload ); + } + + /** + * Return listeners for an event flattened and ordered by priority (ascending). + * + * @param string $event_name Event name. + * + * @return array + */ + private static function get_ordered_listeners(string $event_name): array { + if ( ! isset( self::$events[ $event_name ] ) ) { + return []; + } + + $priority_to_listeners = self::$events[ $event_name ]; + ksort( $priority_to_listeners ); + + $ordered = []; + foreach ( $priority_to_listeners as $listeners_at_priority ) { + foreach ( $listeners_at_priority as $listener ) { + $ordered[] = $listener; + } + } + + return $ordered; + } + + /** + * Return transforms for an event flattened and ordered by priority (ascending). + * + * @param string $event_name Event name. + * + * @return array + */ + private static function get_ordered_transforms(string $event_name): array { + if ( ! isset( self::$transforms[ $event_name ] ) ) { + return []; + } + + $priority_to_transforms = self::$transforms[ $event_name ]; + ksort( $priority_to_transforms ); + + $ordered = []; + foreach ( $priority_to_transforms as $transforms_at_priority ) { + foreach ( $transforms_at_priority as $transform ) { + $ordered[] = $transform; + } + } + + return $ordered; + } + + /** + * Invoke a listener safely; errors are logged and do not break the pipeline. + * + * @param callable $listener Listener. + * @param array $payload Payload for listener. + */ + private static function invoke_listener(callable $listener, array $payload): void { + try { + $listener( $payload ); + } catch ( \Throwable $e ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'WPGraphQL Logging EventManager listener error: ' . $e->getMessage() ); + } + } + + /** + * Invoke a transform safely; returns the updated payload if valid, otherwise the original. + * + * @param callable $transform Transform callable. + * @param array $payload Current payload. + * + * @return array Updated payload. + */ + private static function invoke_transform(callable $transform, array $payload): array { + try { + $result = $transform( $payload ); + if ( is_array( $result ) ) { + return $result; + } + } catch ( \Throwable $e ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'WPGraphQL Logging EventManager transform error: ' . $e->getMessage() ); + } + + return $payload; + } +} diff --git a/plugins/wpgraphql-logging/src/Events/Events.php b/plugins/wpgraphql-logging/src/Events/Events.php new file mode 100644 index 00000000..4049ed9b --- /dev/null +++ b/plugins/wpgraphql-logging/src/Events/Events.php @@ -0,0 +1,42 @@ +logger = $logger; } /** @@ -40,7 +46,6 @@ protected function __construct( readonly LoggerService $logger ) { */ public static function init(): QueryEventLifecycle { if ( null === self::$instance ) { - // @TODO - Add filter to allow for custom logger service. $logger = LoggerService::get_instance(); self::$instance = new self( $logger ); self::$instance->setup(); @@ -50,12 +55,13 @@ public static function init(): QueryEventLifecycle { } /** - * Logs the pre-request event for a GraphQL query. - * This method is hooked into 'do_graphql_request'. + * Initial Incoming Request. + * + * @hook do_graphql_request * - * @param string $query The GraphQL query string. - * @param string|null $operation_name The name of the operation. Made nullable. - * @param array|null $variables The variables for the query. Made nullable. + * @param string $query The GraphQL query string. + * @param string|null $operation_name The name of the operation. Made nullable. + * @param array|null $variables The variables for the query. Made nullable. */ public function log_pre_request( string $query, ?string $operation_name, ?array $variables ): void { try { @@ -65,112 +71,181 @@ public function log_pre_request( string $query, ?string $operation_name, ?array 'operation_name' => $operation_name, ]; - $context = apply_filters( 'wpgraphql_logging_pre_request_context', $context, $query, $variables, $operation_name ); - $level = apply_filters( 'wpgraphql_logging_pre_request_level', Level::Info, $query, $variables, $operation_name ); - $this->logger->log( $level, 'WPGraphQL Incoming Request', $context ); + $payload = EventManager::transform( + Events::PRE_REQUEST, + [ + 'context' => $context, + 'level' => Level::Info, + ] + ); + + $this->logger->log( $payload['level'], 'WPGraphQL Pre Request', $payload['context'] ); + + EventManager::publish( + Events::PRE_REQUEST, + [ + 'context' => $payload['context'], + 'level' => (string) $payload['level']->getName(), + ] + ); } catch ( \Throwable $e ) { - // @TODO - Handle logging errors gracefully. - error_log( 'Error in log_pre_request: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + $this->process_application_error( Events::PRE_REQUEST, $e ); } } /** - * Logs the post-request event for a GraphQL query. - * This method is now hooked into 'graphql_after_execute'. + * Before Request Execution. + * + * @hook graphql_before_execute * - * @param \GraphQL\Executor\ExecutionResult|array $response The GraphQL execution result(s). - * This can be a single ExecutionResult object or an array of them for batch requests. - * @param \WPGraphQL\Request $request_instance The WPGraphQL Request instance. + * @param \WPGraphQL\Request $request The WPGraphQL Request instance. */ - public function log_post_request( $response, Request $request_instance ): void { - // Extract relevant data from the WPGraphQL Request instance - $params = $request_instance->get_params(); // Can be OperationParams or array of OperationParams - $query = null; - $operation_name = null; - $variables = null; - $status_code = 200; // Default success status - - // Handle single or batch requests to get query details - if ( $params instanceof OperationParams ) { - $query = $params->query; - $operation_name = $params->operation; - $variables = $params->variables; - } elseif ( is_array( $params ) && ! empty( $params[0] ) && $params[0] instanceof OperationParams ) { - $query = $params[0]->query; - $operation_name = $params[0]->operation; - $variables = $params[0]->variables; - } + public function log_graphql_before_execute(Request $request ): void { + try { + /** @var \GraphQL\Server\OperationParams $params */ + $params = $request->params; + $context = [ + 'query' => $params->query, + 'operation_name' => $params->operation, + 'variables' => $params->variables, + 'params' => $params, + ]; - // Determine status code if available (WPGraphQL Router sets this) - if ( class_exists( '\WPGraphQL\Router' ) && property_exists( '\WPGraphQL\Router', '$http_status_code' ) ) { - $status_code = \WPGraphQL\Router::$http_status_code; + $payload = EventManager::transform( + Events::BEFORE_GRAPHQL_EXECUTION, + [ + 'context' => $context, + 'level' => Level::Info, + ] + ); + + $this->logger->log( $payload['level'], 'WPGraphQL Before Query Execution', $payload['context'] ); + + EventManager::publish( + Events::BEFORE_GRAPHQL_EXECUTION, + [ + 'context' => $payload['context'], + 'level' => (string) $payload['level']->getName(), + ] + ); + } catch ( \Throwable $e ) { + $this->process_application_error( Events::BEFORE_GRAPHQL_EXECUTION, $e ); } + } - // Extract data and errors from the ExecutionResult object(s) - $response_data = null; - $response_errors = null; - - if ( $response instanceof ExecutionResult ) { - $response_data = $response->data; - $response_errors = $response->errors; - } elseif ( is_array( $response ) && ! empty( $response[0] ) && $response[0] instanceof ExecutionResult ) { - // For batch requests, aggregate data/errors from all results - $response_data = array_map( static fn( $res ) => $res->data, $response ); - $response_errors = array_reduce( $response, static fn( $carry, $res ) => array_merge( $carry, $res->errors ?? [] ), [] ); - if ( empty( $response_errors ) ) { - $response_errors = null; // Ensure it's null if no errors - } - } + /** + * Before the GraphQL response is returned to the client. + * + * @hook graphql_return_response + * + * @param array|\GraphQL\Executor\ExecutionResult $filtered_response The filtered response for the GraphQL request. + * @param array|\GraphQL\Executor\ExecutionResult $response The response for the GraphQL request. + * @param \WPGraphQL\WPSchema $schema The schema object for the root request. + * @param string|null $operation The name of the operation. + * @param string $query The query that GraphQL executed. + * @param array|null $variables Variables passed to your GraphQL query. + * @param \WPGraphQL\Request $request Instance of the Request. + * @param string|null $query_id The query id that GraphQL executed. + */ + public function log_before_response_returned(array|ExecutionResult $filtered_response, array|ExecutionResult $response, WPSchema $schema, ?string $operation, string $query, ?array $variables, Request $request, ?string $query_id): void { try { $context = [ - 'query' => $query, - 'operation_name' => $operation_name, - 'variables' => $variables, - 'status_code' => $status_code, - 'response_data' => $response_data, - 'response_errors' => $response_errors, + 'response' => $response, + 'schema' => $schema, + 'operation_name' => $operation, + 'query' => $query, + 'variables' => $variables, + 'request' => $request, + 'query_id' => $query_id, ]; - $level = Level::Info; - // Apply filters for context and level - $context = apply_filters( 'wpgraphql_logging_post_request_context', $context, $response, $request_instance ); - $level = apply_filters( 'wpgraphql_logging_post_request_level', $level, $response, $request_instance ); - - $this->logger->log( $level, 'WPGraphQL Outgoing Response', $context ); - - // Log errors specifically if present in the response - if ( ! empty( $response_errors ) ) { - $this->logger->error( - 'GraphQL query completed with errors.', - [ - 'query' => $query, - 'operation_name' => $operation_name, - 'status_code' => $status_code, - 'errors' => array_map( static fn( $error ) => $error->getMessage(), $response_errors ), // Extract message from error object - 'full_errors' => $response_errors, // Include full error details for debugging - ] - ); + $level = Level::Info; + $message = 'WPGraphQL Response'; + $errors = $this->get_response_errors( $response ); + if ( null !== $errors && count( $errors ) > 0 ) { + $context['errors'] = $errors; + $level = Level::Error; + $message = 'WPGraphQL Response with Errors'; } + + $payload = EventManager::transform( + Events::BEFORE_RESPONSE_RETURNED, + [ + 'context' => $context, + 'level' => $level, + ] + ); + + $this->logger->log( $payload['level'], $message, $payload['context'] ); + + EventManager::publish( + Events::BEFORE_RESPONSE_RETURNED, + [ + 'context' => $payload['context'], + 'level' => (string) $payload['level']->getName(), + ] + ); } catch ( \Throwable $e ) { - // @TODO - Handle logging errors gracefully. - error_log( 'Error in log_post_request: ' . $e->getMessage() . ' in ' . $e->getFile() . ' on line ' . $e->getLine() ); + $this->process_application_error( Events::BEFORE_RESPONSE_RETURNED, $e ); + } + } + + /** + * Get the context for the response. + * + * @param array|\GraphQL\Executor\ExecutionResult $response The response. + * + * @return array|null + */ + protected function get_response_errors( array|ExecutionResult $response ): ?array { + if ( $response instanceof ExecutionResult && [] !== $response->errors ) { + return $response->errors; + } + + if ( ! is_array( $response ) ) { + return null; + } + + $errors = $response['errors'] ?? null; + if ( null === $errors || [] === $errors ) { + return null; } + + return $errors; } /** - * Register actions and filters. + * Register actions and filters to log the query event lifecycle. + * + * @psalm-suppress HookNotFound */ protected function setup(): void { + /** - * @psalm-suppress HookNotFound + * Initial Incoming Request */ add_action( 'do_graphql_request', [ $this, 'log_pre_request' ], 10, 3 ); /** - * @psalm-suppress HookNotFound + * Before Query Execution + */ + add_action( 'graphql_before_execute', [ $this, 'log_graphql_before_execute' ], 10, 1 ); + + /** + * Response/Error Handling */ - add_action( 'graphql_after_execute', [ $this, 'log_post_request' ], 10, 2 ); + add_action( 'graphql_return_response', [ $this, 'log_before_response_returned' ], 10, 8 ); + } + + /** + * Processing application error when an exception is thrown. + * + * @param string $event The event name. + * @param \Throwable $exception The exception. + */ + protected function process_application_error(string $event, \Throwable $exception): void { + error_log( 'Error for WPGraphQL Logging - ' . $event . ': ' . $exception->getMessage() . ' in ' . $exception->getFile() . ' on line ' . $exception->getLine() ); //phpcs:ignore } } diff --git a/plugins/wpgraphql-logging/src/Logger/LoggerService.php b/plugins/wpgraphql-logging/src/Logger/LoggerService.php index d55a7585..459c1e02 100644 --- a/plugins/wpgraphql-logging/src/Logger/LoggerService.php +++ b/plugins/wpgraphql-logging/src/Logger/LoggerService.php @@ -226,6 +226,8 @@ public static function get_default_processors(): array { new ProcessIdProcessor(), // Logs the process ID. new WPGraphQLQueryProcessor(), // Custom processor to capture GraphQL request data. ]; + + // Filter for users to add their own processors. return apply_filters( 'wpgraphql_logging_default_processors', $default_processors ); } @@ -240,6 +242,8 @@ public static function get_default_handlers(): array { $default_handlers = [ new WordPressDatabaseHandler(), ]; + + // Filter for users to add their own handlers. return apply_filters( 'wpgraphql_logging_default_handlers', $default_handlers ); } @@ -256,6 +260,7 @@ public static function get_default_context(): array { 'site_url' => home_url(), ]; + // Filter for users to modify the default context. return apply_filters( 'wpgraphql_logging_default_context', $context ); } } diff --git a/plugins/wpgraphql-logging/src/Plugin.php b/plugins/wpgraphql-logging/src/Plugin.php index 502860b4..de3d7142 100644 --- a/plugins/wpgraphql-logging/src/Plugin.php +++ b/plugins/wpgraphql-logging/src/Plugin.php @@ -4,6 +4,7 @@ namespace WPGraphQL\Logging; +use WPGraphQL\Logging\Events\EventManager; use WPGraphQL\Logging\Events\QueryEventLifecycle; use WPGraphQL\Logging\Logger\Database\DatabaseEntity; @@ -54,6 +55,39 @@ public function setup(): void { QueryEventLifecycle::init(); } + /** + * Subscribe to an event using the internal EventManager. + * + * @param string $event_name Event name from \WPGraphQL\Logging\Events\Events. + * @param callable $listener Listener callable with signature: function(array $payload): void {}. + * @param int $priority Lower runs earlier. + */ + public static function on( string $event_name, callable $listener, int $priority = 10 ): void { + EventManager::subscribe( $event_name, $listener, $priority ); + } + + /** + * Publish an event to subscribers. + * + * @param string $event_name Event name from \WPGraphQL\Logging\Events\Events. + * @param array $payload Arbitrary payload data. + */ + public static function emit( string $event_name, array $payload = [] ): void { + EventManager::publish( $event_name, $payload ); + } + + /** + * Register a transform for an event payload. The transformer should return + * the (possibly) modified payload array. + * + * @param string $event_name Event name from \WPGraphQL\Logging\Events\Events. + * @param callable $transform function(array $payload): array {}. + * @param int $priority Lower runs earlier. + */ + public static function transform( string $event_name, callable $transform, int $priority = 10 ): void { + EventManager::subscribe_to_transform( $event_name, $transform, $priority ); + } + /** * Activation callback for the plugin. */ @@ -84,7 +118,7 @@ public function __clone() { } /** - * Disable unserializing of the class. + * Disable unserialize of the class. * * @codeCoverageIgnore */ diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/EventManagerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/EventManagerTest.php new file mode 100644 index 00000000..ae427157 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/EventManagerTest.php @@ -0,0 +1,365 @@ +reset_event_manager(); + } + + /** + * Reset the EventManager static state. + */ + private function reset_event_manager(): void { + $reflection = new ReflectionClass(EventManager::class); + + $events_prop = $reflection->getProperty('events'); + $events_prop->setAccessible(true); + $events_prop->setValue(null, []); + + $transforms_prop = $reflection->getProperty('transforms'); + $transforms_prop->setAccessible(true); + $transforms_prop->setValue(null, []); + } + + /** + * Get the internal events array for testing. + */ + private function get_events_array(): array { + $reflection = new ReflectionClass(EventManager::class); + $events_prop = $reflection->getProperty('events'); + $events_prop->setAccessible(true); + return $events_prop->getValue(); + } + + /** + * Get the internal transforms array for testing. + */ + private function get_transforms_array(): array { + $reflection = new ReflectionClass(EventManager::class); + $transforms_prop = $reflection->getProperty('transforms'); + $transforms_prop->setAccessible(true); + return $transforms_prop->getValue(); + } + + public function test_subscribe_adds_listener_to_event(): void { + $listener_called = false; + $listener = function(array $payload) use (&$listener_called) { + $listener_called = true; + }; + + EventManager::subscribe('test_event', $listener); + + $events = $this->get_events_array(); + $this->assertArrayHasKey('test_event', $events); + $this->assertArrayHasKey(10, $events['test_event']); // Default priority + $this->assertCount(1, $events['test_event'][10]); + $this->assertSame($listener, $events['test_event'][10][0]); + } + + public function test_subscribe_with_custom_priority(): void { + $listener1 = function() {}; + $listener2 = function() {}; + + EventManager::subscribe('priority_test', $listener1, 5); + EventManager::subscribe('priority_test', $listener2, 15); + + $events = $this->get_events_array(); + $this->assertArrayHasKey(5, $events['priority_test']); + $this->assertArrayHasKey(15, $events['priority_test']); + $this->assertSame($listener1, $events['priority_test'][5][0]); + $this->assertSame($listener2, $events['priority_test'][15][0]); + } + + public function test_subscribe_multiple_listeners_same_priority(): void { + $listener1 = function() {}; + $listener2 = function() {}; + + EventManager::subscribe('multi_test', $listener1); + EventManager::subscribe('multi_test', $listener2); + + $events = $this->get_events_array(); + $this->assertCount(2, $events['multi_test'][10]); + $this->assertSame($listener1, $events['multi_test'][10][0]); + $this->assertSame($listener2, $events['multi_test'][10][1]); + } + + public function test_publish_calls_subscribed_listeners(): void { + $payload_received = null; + $listener_calls = 0; + + $listener = function(array $payload) use (&$payload_received, &$listener_calls) { + $payload_received = $payload; + $listener_calls++; + }; + + EventManager::subscribe('publish_test', $listener); + + $test_payload = ['test' => 'data']; + EventManager::publish('publish_test', $test_payload); + + $this->assertEquals(1, $listener_calls); + $this->assertEquals($test_payload, $payload_received); + } + + public function test_publish_calls_listeners_in_priority_order(): void { + $call_order = []; + + $high_priority = function() use (&$call_order) { + $call_order[] = 'high'; + }; + + $low_priority = function() use (&$call_order) { + $call_order[] = 'low'; + }; + + $medium_priority = function() use (&$call_order) { + $call_order[] = 'medium'; + }; + + EventManager::subscribe('order_test', $high_priority, 5); // Lower number = higher priority + EventManager::subscribe('order_test', $low_priority, 20); + EventManager::subscribe('order_test', $medium_priority, 10); + + EventManager::publish('order_test'); + + $this->assertEquals(['high', 'medium', 'low'], $call_order); + } + + public function test_publish_with_no_listeners_triggers_wordpress_action(): void { + $action_called = false; + $received_payload = null; + + // Mock WordPress do_action by overriding the global function + $this->mock_wordpress_action('wpgraphql_logging_event_no_listeners', function($payload) use (&$action_called, &$received_payload) { + $action_called = true; + $received_payload = $payload; + }); + + $test_payload = ['empty' => 'test']; + EventManager::publish('no_listeners', $test_payload); + + $this->assertTrue($action_called); + $this->assertEquals($test_payload, $received_payload); + } + + public function test_publish_triggers_wordpress_action_after_listeners(): void { + $execution_order = []; + + $listener = function() use (&$execution_order) { + $execution_order[] = 'listener'; + }; + + EventManager::subscribe('wp_action_test', $listener); + + $this->mock_wordpress_action('wpgraphql_logging_event_wp_action_test', function() use (&$execution_order) { + $execution_order[] = 'wp_action'; + }); + + EventManager::publish('wp_action_test'); + + $this->assertEquals(['listener', 'wp_action'], $execution_order); + } + + public function test_subscribe_to_transform_adds_transformer(): void { + $transformer = function(array $payload): array { + return $payload; + }; + + EventManager::subscribe_to_transform('transform_test', $transformer); + + $transforms = $this->get_transforms_array(); + $this->assertArrayHasKey('transform_test', $transforms); + $this->assertArrayHasKey(10, $transforms['transform_test']); + $this->assertSame($transformer, $transforms['transform_test'][10][0]); + } + + public function test_transform_modifies_payload(): void { + $transformer = function(array $payload): array { + $payload['transformed'] = true; + return $payload; + }; + + EventManager::subscribe_to_transform('modify_test', $transformer); + + $original = ['original' => 'data']; + $result = EventManager::transform('modify_test', $original); + + $this->assertTrue($result['transformed']); + $this->assertEquals('data', $result['original']); + } + + public function test_transform_with_multiple_transformers_in_priority_order(): void { + $first_transformer = function(array $payload): array { + $payload['order'][] = 'first'; + return $payload; + }; + + $second_transformer = function(array $payload): array { + $payload['order'][] = 'second'; + return $payload; + }; + + EventManager::subscribe_to_transform('multi_transform', $first_transformer, 5); + EventManager::subscribe_to_transform('multi_transform', $second_transformer, 10); + + $result = EventManager::transform('multi_transform', ['order' => []]); + + $this->assertEquals(['first', 'second'], $result['order']); + } + + public function test_transform_with_no_transformers_applies_wordpress_filter(): void { + $filter_applied = false; + $received_payload = null; + + $this->mock_wordpress_filter('wpgraphql_logging_filter_filter_test', function($payload) use (&$filter_applied, &$received_payload) { + $filter_applied = true; + $received_payload = $payload; + $payload['filtered'] = true; + return $payload; + }); + + $original = ['test' => 'data']; + $result = EventManager::transform('filter_test', $original); + + $this->assertTrue($filter_applied); + $this->assertEquals($original, $received_payload); + $this->assertTrue($result['filtered']); + } + + public function test_transform_applies_wordpress_filter_after_transformers(): void { + $execution_order = []; + + $transformer = function(array $payload) use (&$execution_order): array { + $execution_order[] = 'transformer'; + return $payload; + }; + + EventManager::subscribe_to_transform('filter_order_test', $transformer); + + $this->mock_wordpress_filter('wpgraphql_logging_filter_filter_order_test', function($payload) use (&$execution_order) { + $execution_order[] = 'filter'; + return $payload; + }); + + EventManager::transform('filter_order_test', []); + + $this->assertEquals(['transformer', 'filter'], $execution_order); + } + + public function test_listener_exceptions_are_caught_and_logged(): void { + $good_listener_called = false; + $exception_thrown = false; + + $bad_listener = function() use (&$exception_thrown) { + $exception_thrown = true; + throw new \Exception('Test exception'); + }; + + $good_listener = function() use (&$good_listener_called) { + $good_listener_called = true; + }; + + EventManager::subscribe('exception_test', $bad_listener, 5); + EventManager::subscribe('exception_test', $good_listener, 10); + + // Publishing should not throw an exception even if a listener throws + EventManager::publish('exception_test'); + + $this->assertTrue($exception_thrown, 'Exception should have been thrown by bad listener'); + $this->assertTrue($good_listener_called, 'Good listener should still be called after exception'); + } + + public function test_transformer_exceptions_are_caught_and_logged(): void { + $exception_thrown = false; + + $bad_transformer = function(array $payload) use (&$exception_thrown): array { + $exception_thrown = true; + throw new \Exception('Transform exception'); + }; + + EventManager::subscribe_to_transform('transform_exception_test', $bad_transformer); + + $original = ['test' => 'data']; + $result = EventManager::transform('transform_exception_test', $original); + + $this->assertTrue($exception_thrown, 'Exception should have been thrown by bad transformer'); + $this->assertEquals($original, $result, 'Original payload should be returned on exception'); + } + + public function test_transformer_returning_non_array_is_ignored(): void { + $transformer_called = false; + + $bad_transformer = function(array $payload) use (&$transformer_called) { + $transformer_called = true; + return 'not an array'; + }; + + EventManager::subscribe_to_transform('bad_return_test', $bad_transformer); + + $original = ['test' => 'data']; + $result = EventManager::transform('bad_return_test', $original); + + $this->assertTrue($transformer_called, 'Transformer should have been called'); + $this->assertEquals($original, $result, 'Original payload should be returned when transformer returns non-array'); + } + + /** + * Data provider for event constants. + */ + public function eventConstantsProvider(): array { + return [ + [Events::PRE_REQUEST, 'do_graphql_request'], + [Events::BEFORE_GRAPHQL_EXECUTION, 'graphql_before_execute'], + [Events::BEFORE_RESPONSE_RETURNED, 'graphql_return_response'], + ]; + } + + /** + * @dataProvider eventConstantsProvider + */ + public function test_works_with_event_constants(string $event_constant, string $expected_value): void { + $this->assertEquals($expected_value, $event_constant); + + $listener_called = false; + $listener = function() use (&$listener_called) { + $listener_called = true; + }; + + EventManager::subscribe($event_constant, $listener); + EventManager::publish($event_constant); + + $this->assertTrue($listener_called, "Listener should be called for event: {$event_constant}"); + } + + /** + * Helper method to mock WordPress do_action function. + */ + private function mock_wordpress_action(string $action_name, callable $callback): void { + add_action($action_name, $callback); + } + + /** + * Helper method to mock WordPress apply_filters function. + */ + private function mock_wordpress_filter(string $filter_name, callable $callback): void { + add_filter($filter_name, $callback); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/QueryEventLifecycleTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/QueryEventLifecycleTest.php new file mode 100644 index 00000000..855f4529 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/QueryEventLifecycleTest.php @@ -0,0 +1,472 @@ +test_handler = new TestHandler(); + $this->mock_logger = LoggerService::get_instance('test_lifecycle', [$this->test_handler], [], []); + $this->reset_lifecycle_instance(); + $this->reset_event_manager(); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + parent::tearDown(); + $this->reset_lifecycle_instance(); + $this->reset_event_manager(); + $this->reset_logger_instances(); + } + + /** + * Reset QueryEventLifecycle singleton state. + */ + private function reset_lifecycle_instance(): void { + $reflection = new ReflectionClass(QueryEventLifecycle::class); + $instance_prop = $reflection->getProperty('instance'); + $instance_prop->setAccessible(true); + $instance_prop->setValue(null, null); + } + + /** + * Reset EventManager static state. + */ + private function reset_event_manager(): void { + $reflection = new ReflectionClass(EventManager::class); + + $events_prop = $reflection->getProperty('events'); + $events_prop->setAccessible(true); + $events_prop->setValue(null, []); + + $transforms_prop = $reflection->getProperty('transforms'); + $transforms_prop->setAccessible(true); + $transforms_prop->setValue(null, []); + } + + /** + * Reset LoggerService instances. + */ + private function reset_logger_instances(): void { + $reflection = new ReflectionClass(LoggerService::class); + $instances_prop = $reflection->getProperty('instances'); + $instances_prop->setAccessible(true); + $instances_prop->setValue(null, []); + } + + /** + * Create a mock LoggerService instance with injected test handler. + */ + private function create_lifecycle_with_mock_logger(): QueryEventLifecycle { + $reflection = new ReflectionClass(QueryEventLifecycle::class); + $lifecycle = $reflection->newInstanceWithoutConstructor(); + + $logger_prop = $reflection->getProperty('logger'); + $logger_prop->setAccessible(true); + $logger_prop->setValue($lifecycle, $this->mock_logger); + + return $lifecycle; + } + + /** + * Create a mock WPGraphQL Request object. + */ + private function create_mock_request(string $query = '{ posts { title } }', ?string $operation_name = null, ?array $variables = null): Request { + $mock_request = $this->createMock(Request::class); + + $mock_params = new \stdClass(); + $mock_params->query = $query; + $mock_params->operation = $operation_name; + $mock_params->variables = $variables; + + $mock_request->params = $mock_params; + + return $mock_request; + } + + /** + * Create a mock WPGraphQL Schema object. + */ + private function create_mock_schema(): WPSchema { + return $this->createMock(WPSchema::class); + } + + /** + * Create a mock ExecutionResult object. + */ + private function create_mock_execution_result(array $data = [], array $errors = []): ExecutionResult { + $mock_result = $this->createMock(ExecutionResult::class); + $mock_result->data = $data; + $mock_result->errors = $errors; + return $mock_result; + } + + public function test_init_returns_singleton_instance(): void { + $instance1 = QueryEventLifecycle::init(); + $instance2 = QueryEventLifecycle::init(); + + $this->assertInstanceOf(QueryEventLifecycle::class, $instance1); + $this->assertSame($instance1, $instance2, 'init() should return the same singleton instance'); + } + + public function test_init_creates_new_instance_when_none_exists(): void { + $reflection = new ReflectionClass(QueryEventLifecycle::class); + $instance_prop = $reflection->getProperty('instance'); + $instance_prop->setAccessible(true); + + $this->assertNull($instance_prop->getValue()); + + $instance = QueryEventLifecycle::init(); + + $this->assertInstanceOf(QueryEventLifecycle::class, $instance); + $this->assertSame($instance, $instance_prop->getValue()); + } + + public function test_log_pre_request_logs_correctly(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $query = '{ posts { title } }'; + $operation_name = 'GetPosts'; + $variables = ['limit' => 10]; + + $lifecycle->log_pre_request($query, $operation_name, $variables); + + $this->assertTrue($this->test_handler->hasInfoRecords()); + $records = $this->test_handler->getRecords(); + $this->assertCount(1, $records); + + $record = $records[0]; + $this->assertEquals('WPGraphQL Pre Request', $record['message']); + $this->assertEquals($query, $record['context']['query']); + $this->assertEquals($operation_name, $record['context']['operation_name']); + $this->assertEquals($variables, $record['context']['variables']); + } + + public function test_log_pre_request_handles_null_values(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $query = '{ posts { title } }'; + + $lifecycle->log_pre_request($query, null, null); + + $this->assertTrue($this->test_handler->hasInfoRecords()); + $records = $this->test_handler->getRecords(); + $record = $records[0]; + + $this->assertEquals($query, $record['context']['query']); + $this->assertNull($record['context']['operation_name']); + $this->assertNull($record['context']['variables']); + } + + public function test_log_graphql_before_execute_logs_correctly(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $query = '{ posts { title } }'; + $operation_name = 'GetPosts'; + $variables = ['limit' => 10]; + $request = $this->create_mock_request($query, $operation_name, $variables); + + $lifecycle->log_graphql_before_execute($request); + + $this->assertTrue($this->test_handler->hasInfoRecords()); + $records = $this->test_handler->getRecords(); + $record = $records[0]; + + $this->assertEquals('WPGraphQL Before Query Execution', $record['message']); + $this->assertEquals($query, $record['context']['query']); + $this->assertEquals($operation_name, $record['context']['operation_name']); + $this->assertEquals($variables, $record['context']['variables']); + $this->assertEquals($request->params, $record['context']['params']); + } + + + + + + public function test_log_before_response_returned_logs_info_for_success(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $filtered_response = $this->create_mock_execution_result(['posts' => []]); + $response = $this->create_mock_execution_result(['posts' => []]); + $schema = $this->create_mock_schema(); + $operation = 'GetPosts'; + $query = '{ posts { title } }'; + $variables = ['limit' => 10]; + $request = $this->create_mock_request(); + $query_id = 'query_123'; + + $lifecycle->log_before_response_returned($filtered_response, $response, $schema, $operation, $query, $variables, $request, $query_id); + + $this->assertTrue($this->test_handler->hasInfoRecords()); + $records = $this->test_handler->getRecords(); + $record = $records[0]; + + $this->assertEquals(200, $record['level']); + $this->assertEquals('WPGraphQL Response', $record['message']); + } + + public function test_log_before_response_returned_logs_error_for_errors(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $errors = [['message' => 'Field not found']]; + $response = $this->create_mock_execution_result([], $errors); + $schema = $this->create_mock_schema(); + $query = '{ invalidField }'; + $request = $this->create_mock_request(); + + $lifecycle->log_before_response_returned($response, $response, $schema, null, $query, null, $request, null); + + $this->assertTrue($this->test_handler->hasErrorRecords()); + $records = $this->test_handler->getRecords(); + $record = $records[0]; + + $this->assertEquals('WPGraphQL Response with Errors', $record['message']); + $this->assertEquals(400, $record['level']); + $this->assertEquals($errors, $record['context']['errors']); + } + + public function test_get_response_errors_extracts_from_execution_result(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('get_response_errors'); + $method->setAccessible(true); + + $errors = [['message' => 'Test error']]; + $response = $this->create_mock_execution_result([], $errors); + + $result = $method->invoke($lifecycle, $response); + + $this->assertEquals($errors, $result); + } + + public function test_get_response_errors_extracts_from_array(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('get_response_errors'); + $method->setAccessible(true); + + $errors = [['message' => 'Test error']]; + $response = ['data' => [], 'errors' => $errors]; + + $result = $method->invoke($lifecycle, $response); + + $this->assertEquals($errors, $result); + } + + public function test_get_response_errors_returns_null_for_no_errors(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('get_response_errors'); + $method->setAccessible(true); + + $response = $this->create_mock_execution_result(['data' => []], []); + + $result = $method->invoke($lifecycle, $response); + + $this->assertNull($result); + } + + public function test_get_response_errors_returns_null_for_empty_errors_array(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + $reflection = new ReflectionClass($lifecycle); + $method = $reflection->getMethod('get_response_errors'); + $method->setAccessible(true); + + $response = ['data' => [], 'errors' => []]; + + $result = $method->invoke($lifecycle, $response); + + $this->assertNull($result); + } + + public function test_integration_with_event_manager_transform(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $transform_called = false; + $received_payload = null; + + EventManager::subscribe_to_transform(Events::PRE_REQUEST, function($payload) use (&$transform_called, &$received_payload) { + $transform_called = true; + $received_payload = $payload; + $payload['context']['transformed'] = true; + return $payload; + }); + + $lifecycle->log_pre_request('{ test }', null, null); + + $this->assertTrue($transform_called); + $this->assertArrayHasKey('context', $received_payload); + $this->assertEquals('{ test }', $received_payload['context']['query']); + + $records = $this->test_handler->getRecords(); + $this->assertTrue($records[0]['context']['transformed']); + } + + public function test_integration_with_event_manager_publish(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $publish_called = false; + $received_payload = null; + + EventManager::subscribe(Events::PRE_REQUEST, function($payload) use (&$publish_called, &$received_payload) { + $publish_called = true; + $received_payload = $payload; + }); + + $lifecycle->log_pre_request('{ test }', null, null); + + $this->assertTrue($publish_called); + $this->assertArrayHasKey('context', $received_payload); + $this->assertArrayHasKey('level', $received_payload); + $this->assertEquals('INFO', $received_payload['level']); + } + + public function test_exception_handling_in_log_pre_request(): void { + // Create a lifecycle with a mock logger that throws an exception + $mock_logger = $this->createMock(LoggerService::class); + $mock_logger->method('log')->willThrowException(new \Exception('Logger error')); + + $reflection = new ReflectionClass(QueryEventLifecycle::class); + $lifecycle = $reflection->newInstanceWithoutConstructor(); + + $logger_prop = $reflection->getProperty('logger'); + $logger_prop->setAccessible(true); + $logger_prop->setValue($lifecycle, $mock_logger); + + // Should not throw an exception + $lifecycle->log_pre_request('{ test }', null, null); + + // The test passes if no exception is thrown + $this->assertTrue(true); + } + + + public function test_setup_registers_correct_priorities(): void { + $lifecycle = QueryEventLifecycle::init(); + + $this->assertEquals(10, has_action('do_graphql_request', [$lifecycle, 'log_pre_request'])); + $this->assertEquals(10, has_action('graphql_before_execute', [$lifecycle, 'log_graphql_before_execute'])); + $this->assertEquals(10, has_action('graphql_return_response', [$lifecycle, 'log_before_response_returned'])); + } + + /** + * Data provider for lifecycle methods. + */ + public function lifecycleMethodsProvider(): array { + return [ + [ + 'method' => 'log_pre_request', + 'event' => Events::PRE_REQUEST, + 'args' => ['{ test }', null, null], + 'expected_message' => 'WPGraphQL Pre Request', + ], + [ + 'method' => 'log_graphql_before_execute', + 'event' => Events::BEFORE_GRAPHQL_EXECUTION, + 'args' => [null], // Will be replaced with mock request + 'expected_message' => 'WPGraphQL Before Query Execution', + ], + ]; + } + + /** + * @dataProvider lifecycleMethodsProvider + */ + public function test_lifecycle_methods_use_correct_events(string $method, string $event, array $args, string $expected_message): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + + $event_published = false; + EventManager::subscribe($event, function() use (&$event_published) { + $event_published = true; + }); + + if ($method === 'log_graphql_before_execute') { + $args[0] = $this->create_mock_request(); + } + + $lifecycle->$method(...$args); + + $this->assertTrue($event_published, "Event {$event} should be published by {$method}"); + + $records = $this->test_handler->getRecords(); + $this->assertEquals($expected_message, $records[0]['message']); + } + + public function test_process_application_error_method_exists(): void { + $lifecycle = $this->create_lifecycle_with_mock_logger(); + $reflection = new ReflectionClass($lifecycle); + + $this->assertTrue($reflection->hasMethod('process_application_error')); + + $method = $reflection->getMethod('process_application_error'); + $method->setAccessible(true); + + // Should not throw an exception when called + $exception = new \Exception('Test exception'); + $method->invoke($lifecycle, Events::PRE_REQUEST, $exception); + + $this->assertTrue(true); + } + + public function test_all_lifecycle_methods_handle_exceptions_gracefully(): void { + // Create a mock logger that always throws exceptions + $mock_logger = $this->createMock(LoggerService::class); + $mock_logger->method('log')->willThrowException(new \Exception('Logger error')); + + $reflection = new ReflectionClass(QueryEventLifecycle::class); + $lifecycle = $reflection->newInstanceWithoutConstructor(); + + $logger_prop = $reflection->getProperty('logger'); + $logger_prop->setAccessible(true); + $logger_prop->setValue($lifecycle, $mock_logger); + + // Test all lifecycle methods - none should throw exceptions + $lifecycle->log_pre_request('{ test }', null, null); + + $request = $this->create_mock_request(); + $lifecycle->log_graphql_before_execute($request); + + $response = $this->create_mock_execution_result(); + $schema = $this->create_mock_schema(); + + $lifecycle->log_before_response_returned($response, $response, $schema, null, '{ test }', null, $request, null); + + $this->assertTrue(true, 'All lifecycle methods should handle exceptions gracefully'); + } +}