Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ add_action( 'plugins_loaded', function() {

With this in place, all logs from WPGraphQL Logging will be routed through your `MyFileLogService` and saved to a file instead of the database.

## Supporting search in the admin

The search box on GraphQL Logs -> All Logs passes the search term to `find_entities_by_where()` and `count_entities_by_where()` as a where condition with the pseudo-column `LogServiceInterface::SEARCH_COLUMN`:

```php
[
'column' => LogServiceInterface::SEARCH_COLUMN, // 'search'
'operator' => 'LIKE',
'value' => 'GetPosts', // The raw search term, not a LIKE pattern.
]
```

Your service decides which fields to match the term against. If it doesn't support search, it can ignore this condition.

## Contributing

We welcome and appreciate contributions from the community. If you'd like to help improve this documentation, please check out our [Contributing Guide](https://github.com/wpengine/hwptoolkit/blob/main/CONTRIBUTING.md) for more details on how to get started.
10 changes: 9 additions & 1 deletion docs/plugins/wpgraphql-logging/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The plugin provides comprehensive data management capabilities, allowing you to

### Admin UI

The plugin delivers a admin UI with some filtering of logs around dates or level and the ability to download logs in CSV format.
The plugin delivers a admin UI with a search box, some filtering of logs around dates or level and the ability to download logs in CSV format.

***

Expand Down Expand Up @@ -142,6 +142,14 @@ ID,Date,Level,"Level Name",Message,Channel,Query,Context,Extra
5293,"2025-10-06 15:41:34",200,INFO,"WPGraphQL Response",wpgraphql_logging,"{ posts(first: 10) ...""memory_peak_usage"":""18 MB""}"
```

### Searching Logs

Use the search box to find logs by their message (for example `WPGraphQL Response with Errors`) or by the text of the GraphQL query, such as an operation name like `GetPosts`. The search is case-insensitive, and it works together with the filters, sorting and pagination.

To stay fast on large log tables, the search only looks at the message and the `query` in the log context, not the variables, request or response. A `LIKE '%term%'` search can't use an index, so combine it with a date or level filter when you have many logs.

Developers can change or remove the search condition with the `wpgraphql_logging_logs_table_where_clauses` filter.

### Filtering Logs

You can also filter logs with the following filters:
Expand Down
23 changes: 20 additions & 3 deletions plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ class ListTable extends WP_List_Table {
*/
public const DEFAULT_PER_PAGE = 20;

/**
* Maximum length of the search term.
*
* @var int
*/
public const MAX_SEARCH_LENGTH = 200;

/**
* Constructor.
*
Expand Down Expand Up @@ -164,12 +171,11 @@ public function process_bulk_action(): void {
}

$preserved_filters = [];
$filter_keys = [ 'level_filter', 'start_date', 'end_date' ];

foreach ( $filter_keys as $key ) {
foreach ( ViewLogsPage::FILTER_KEYS as $key ) {
$value = isset( $_REQUEST[ $key ] ) && is_string( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : null;
if ( null !== $value && '' !== $value ) {
$preserved_filters[ $key ] = $value;
$preserved_filters[ $key ] = rawurlencode( $value );
}
}

Expand Down Expand Up @@ -467,6 +473,17 @@ protected function process_where(array $request): array {
];
}

if ( isset( $request['s'] ) && is_string( $request['s'] ) ) {
$search = mb_substr( sanitize_text_field( wp_unslash( $request['s'] ) ), 0, self::MAX_SEARCH_LENGTH );
if ( '' !== $search ) {
$where_clauses[] = [
'column' => LogServiceInterface::SEARCH_COLUMN,
'operator' => 'LIKE',
'value' => $search,
];
}
}

// Allow developers to modify the where clauses.
return apply_filters( 'wpgraphql_logging_logs_table_where_clauses', $where_clauses, $request );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@
exit;
}

$wpgraphql_logging_current_level = isset( $_POST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_POST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$wpgraphql_logging_current_start_date = isset( $_POST['start_date'] ) ? sanitize_text_field( wp_unslash( $_POST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
$wpgraphql_logging_current_end_date = isset( $_POST['end_date'] ) ? sanitize_text_field( wp_unslash( $_POST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
// Filters are submitted by POST and redirected to GET, so read them from the request.
$wpgraphql_logging_current_level = isset( $_REQUEST['level_filter'] ) && is_string( $_REQUEST['level_filter'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['level_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$wpgraphql_logging_current_start_date = isset( $_REQUEST['start_date'] ) && is_string( $_REQUEST['start_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['start_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$wpgraphql_logging_current_end_date = isset( $_REQUEST['end_date'] ) && is_string( $_REQUEST['end_date'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['end_date'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended

// Currently only use info and error.
$wpgraphql_logging_log_levels = [ 'info', 'error' ];
$wpgraphql_logging_log_levels = apply_filters( 'wpgraphql_logging_log_levels', $wpgraphql_logging_log_levels );
?>

<div class="alignleft actions wpgraphql-logging-filters">
<?php wp_nonce_field( 'wpgraphql_logging_filter', 'wpgraphql_logging_nonce', false ); ?>

<input type="text"
name="start_date"
class="wpgraphql-logging-datepicker"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

<form method="post">
<?php $list_table->prepare_items(); ?>
<?php $list_table->search_box( __( 'Search Logs', 'wpgraphql-logging' ), 'wpgraphql-logging' ); ?>
<?php $list_table->display(); ?>
</form>
</div>
65 changes: 56 additions & 9 deletions plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ class ViewLogsPage {
*/
public const ADMIN_PAGE_DOWNLOAD_NONCE = 'wp_graphql_logging_download';

/**
* The request keys for the search and filters of the logs list.
*
* @var array<string>
*/
public const FILTER_KEYS = [ 'start_date', 'end_date', 'level_filter', 's' ];

/**
* The hook suffix for the admin page.
*
Expand Down Expand Up @@ -229,6 +236,11 @@ public function process_filters_redirect(): void {
return;
}

// The filters share a form with the bulk actions, which ListTable handles itself.
if ( $this->is_bulk_action_request() ) {
return;
}

$redirect_url = $this->get_redirect_url();

wp_safe_redirect( $redirect_url );
Expand All @@ -243,13 +255,7 @@ public function process_filters_redirect(): void {
public function get_redirect_url(): string {
$redirect_url = menu_page_url( self::ADMIN_PAGE_SLUG, false );

$possible_filters = [
'start_date',
'end_date',
'level_filter',
'orderby',
'order',
];
$possible_filters = [ ...self::FILTER_KEYS, 'orderby', 'order' ];
$filters = [];
foreach ( $possible_filters as $key ) {
$value = $this->get_post_value( $key );
Expand All @@ -258,13 +264,54 @@ public function get_redirect_url(): string {
}
}

$redirect_url = add_query_arg( array_filter( $filters, static function ( $value ) {
// Keep the typed page number only when the search and filters did not change.
$paged = $this->get_post_value( 'paged' );
if ( null !== $paged && absint( $paged ) > 1 && ! $this->filters_changed( $filters ) ) {
$filters['paged'] = (string) absint( $paged );
}

// add_query_arg() does not encode values, and a search term can contain "&", "#" or "{".
$redirect_url = add_query_arg( array_map( 'rawurlencode', array_filter( $filters, static function ( $value ) {
return '' !== $value;
} ), $redirect_url );
} ) ), $redirect_url );

return (string) apply_filters( 'wpgraphql_logging_filter_redirect_url', $redirect_url, $filters );
}

/**
* Whether the submitted search or filters differ from the ones in the current URL.
*
* @param array<string, string> $filters The submitted filters.
*
* @phpcs:disable WordPress.Security.NonceVerification.Recommended
*
* @return bool True if any search or filter value changed.
*/
protected function filters_changed(array $filters): bool {
foreach ( self::FILTER_KEYS as $key ) {
$current = isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : '';
if ( ( $filters[ $key ] ?? '' ) !== $current ) {
return true;
}
}
return false;
}

/**
* Whether the submitted form is requesting a bulk action.
*
* @return bool True if a bulk action other than the "-1" placeholder was selected.
*/
protected function is_bulk_action_request(): bool {
foreach ( [ 'action', 'action2' ] as $key ) {
$value = $this->get_post_value( $key );
if ( null !== $value && '-1' !== $value ) {
return true;
}
}
return false;
}

/**
* Retrieves and sanitizes a value from the $_POST superglobal.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@
* @since 0.0.1
*/
interface LogServiceInterface {
/**
* Pseudo-column used in where conditions for a free-text search.
*
* A condition of `[ 'column' => self::SEARCH_COLUMN, 'operator' => 'LIKE', 'value' => $term ]`
* asks the service to match `$term` against whichever fields it considers searchable.
* Services that do not support search may ignore it.
*
* @var string
*/
public const SEARCH_COLUMN = 'search';

/**
* Activates the log service.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ protected function prepare_sql(string $sql, array $where_conditions): string {
if ( '' === $column ) {
continue;
}
if ( self::SEARCH_COLUMN === $column ) {
$where_clauses[] = $this->prepare_search_clause( $condition );
continue;
}
if ( ! in_array( $column, $allowed_columns, true ) ) {
continue;
}
Expand All @@ -264,13 +268,50 @@ protected function prepare_sql(string $sql, array $where_conditions): string {
$this->where_values[] = $value;
}

$where_clauses = array_filter( $where_clauses, static fn( string $clause ): bool => '' !== $clause );
if ( ! empty( $where_clauses ) ) {
$sql .= ' WHERE ' . implode( ' AND ', $where_clauses );
}

return $sql;
}

/**
* Builds the where clause for a free-text search condition.
*
* Only the message column and the top-level `query` key of the context JSON are searched.
* A leading-wildcard LIKE cannot use an index, so the set is kept small on purpose: the
* rest of the context (variables, request, response) can be large and would match noise.
* Filtering by date or level narrows the rows first via the existing indexes.
*
* @param array{column: string, operator: string, value: string} $condition The search condition.
*
* @return string The where clause, or an empty string if the condition is invalid.
*/
protected function prepare_search_clause(array $condition): string {
global $wpdb;

if ( 'LIKE' !== $condition['operator'] || ! is_string( $condition['value'] ) ) {
return '';
}

$term = trim( $condition['value'] );
if ( '' === $term ) {
return '';
}

$like = '%' . $wpdb->esc_like( $term ) . '%';

// JSON_UNQUOTE() returns a binary collation on MySQL, so compare lowercase to stay case-insensitive.
$this->where_values[] = 'message';
$this->where_values[] = $like;
$this->where_values[] = 'context';
$this->where_values[] = '$.query';
$this->where_values[] = $like;

return '(%i LIKE %s OR LOWER(JSON_UNQUOTE(JSON_EXTRACT(%i, %s))) LIKE LOWER(%s))';
}

/**
* The safe operators.
*
Expand Down
80 changes: 80 additions & 0 deletions plugins/wpgraphql-logging/tests/e2e/specs/search-logs.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { expect, test } from "@wordpress/e2e-test-utils-playwright";
import {
goToLoggingSettingsPage,
goToLogsListPage,
configureLogging,
executeGraphQLQuery,
resetPluginSettings,
} from "../utils";
import { GET_POSTS_QUERY } from "../constants";

const GET_SETTINGS_QUERY = `
query GetSiteSettings {
generalSettings {
title
}
}
`;

test.describe("Search Logs", () => {
test.beforeEach(async ({ admin, page, request }) => {
await resetPluginSettings(admin);

await goToLoggingSettingsPage(admin);
await configureLogging(page, {
enabled: true,
dataSampling: "100",
eventLogSelection: ["graphql_request_results"],
});

expect((await executeGraphQLQuery(request, GET_POSTS_QUERY)).ok()).toBeTruthy();
expect((await executeGraphQLQuery(request, GET_SETTINGS_QUERY)).ok()).toBeTruthy();
});

test("finds logs by query text", async ({ page, admin }) => {
await goToLogsListPage(admin);

const rows = page.locator("#the-list tr");
await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(1);
await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1);

const searchInput = page.locator("#wpgraphql-logging-search-input");
await searchInput.fill("getsitesettings");
await searchInput.press("Enter");

// The search is redirected to GET so it stays in the pagination and sort links.
await expect(page).toHaveURL(/[?&]s=getsitesettings(&|$)/);
await expect(searchInput).toHaveValue("getsitesettings");
await expect(rows.filter({ hasText: "GetSiteSettings" })).toHaveCount(1);
await expect(rows.filter({ hasText: "GetPosts" })).toHaveCount(0);
});

test("combines search with the level filter", async ({ page, admin }) => {
await goToLogsListPage(admin);

await page.locator("#wpgraphql-logging-search-input").fill("GetPosts");
await page.locator('select[name="level_filter"]').selectOption("error");
await page.getByRole("button", { name: "Filter", exact: true }).click();

await expect(page).toHaveURL(/level_filter=error/);
await expect(page).toHaveURL(/[?&]s=GetPosts(&|$)/);
await expect(page.locator('select[name="level_filter"]')).toHaveValue("error");
await expect(
page.locator('td.colspanchange:has-text("No items found.")')
).toBeVisible();
});

test("shows no results for an unmatched search", async ({ page, admin }) => {
await goToLogsListPage(admin);

const searchInput = page.locator("#wpgraphql-logging-search-input");
await searchInput.fill("NoSuchOperation");
await searchInput.press("Enter");

await expect(
page.locator('td.colspanchange:has-text("No items found.")')
).toBeVisible();
// The search box stays visible so the search can be changed.
await expect(searchInput).toHaveValue("NoSuchOperation");
});
});
Loading
Loading