Skip to content
8 changes: 6 additions & 2 deletions docs/plugins/wpgraphql-logging/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,11 @@ Once setup, you can view logs under GraphQL Logs -> All Logs. The admin screen i

### Downloading Logs

You can download individual logs in CSV format.
You can download logs in CSV format, either one at a time with the "Download" row action, or several at once by checking them and choosing "Download Selected" from the bulk actions. A bulk download gives you one CSV with a header row and one row per selected log.

Bulk downloads are streamed straight to the browser one row at a time, so no temporary files are written. A single bulk download is capped at 1,000 logs. You can change the cap with the `wpgraphql_logging_bulk_download_limit` filter, and rename the file with `wpgraphql_logging_bulk_csv_filename`.

Cells that a spreadsheet would treat as a formula (starting with `=`, `+`, `-`, `@` and similar) are prefixed with `'`, because log values can come from unauthenticated GraphQL requests.

**Example**

Expand Down Expand Up @@ -178,7 +182,7 @@ You can also filter logs with the following filters:

### Bulk Actions

You can delete selected logs or all logs using the bulk action controls.
You can download selected logs, delete selected logs, or delete all logs using the bulk action controls.

## Uninstallation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace WPGraphQL\Logging\Admin\View\Download;

use Generator;
use League\Csv\EscapeFormula;
use League\Csv\Writer;
use WPGraphQL\Logging\Logger\Api\LogEntityInterface;
use WPGraphQL\Logging\Logger\Api\LogServiceInterface;
Expand All @@ -16,6 +18,13 @@
* @since 0.0.1
*/
class DownloadLogService {
/**
* Default maximum number of log entries in a single bulk download.
*
* @var int
*/
public const DEFAULT_BULK_DOWNLOAD_LIMIT = 1000;

/**
* Constructor.
*
Expand All @@ -30,9 +39,7 @@ public function __construct(protected readonly LogServiceInterface $log_service)
* @param int $log_id The ID of the log to download.
*/
public function generate_csv( int $log_id ): void {
if ( ! current_user_can( 'manage_options' ) || ! is_admin() ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wpgraphql-logging' ) );
}
$this->verify_permissions();

if ( 0 === $log_id ) {
wp_die( esc_html__( 'Invalid log ID.', 'wpgraphql-logging' ) );
Expand All @@ -44,26 +51,92 @@ public function generate_csv( int $log_id ): void {
wp_die( esc_html__( 'Log not found.', 'wpgraphql-logging' ) );
}

// Set headers for CSV download.
$filename = apply_filters( 'wpgraphql_logging_csv_filename', 'graphql_log_' . $log_id . '.csv' );
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' );
header( 'Expires: 0' );
$this->stream_csv( (string) $filename, [ $log ] );
}

// Create CSV.
$output = fopen( 'php://output', 'w' );
if ( ! is_resource( $output ) ) {
wp_die( esc_html__( 'Failed to create CSV output.', 'wpgraphql-logging' ) );
/**
* Generates and serves a single CSV file containing multiple log entries.
*
* Entries are fetched and written one at a time, straight to the output
* stream, so memory use stays flat regardless of how many are selected.
*
* @param array<mixed> $log_ids The IDs of the logs to download.
*/
public function generate_bulk_csv( array $log_ids ): void {
$this->verify_permissions();

$log_ids = $this->sanitize_log_ids( $log_ids );
if ( [] === $log_ids ) {
wp_die( esc_html__( 'No logs selected.', 'wpgraphql-logging' ) );
}
$writer = Writer::from( $output );

$headers = $this->get_headers( $log );
$content = $this->get_content( $log );
$writer->insertOne( $headers );
$writer->insertOne( $content );
fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the php://output stream, which WP_Filesystem can't handle.
exit;
$logs = $this->get_logs( $log_ids );

// Starts the generator, so we can bail before any headers are sent.
if ( ! $logs->valid() ) {
wp_die( esc_html__( 'Logs not found.', 'wpgraphql-logging' ) );
}

$filename = apply_filters( 'wpgraphql_logging_bulk_csv_filename', 'graphql_logs_' . gmdate( 'Y-m-d_His' ) . '.csv', $log_ids );
$this->stream_csv( (string) $filename, $logs );
}

/**
* Sanitizes a list of log IDs for a bulk download.
*
* Removes non-scalar, invalid and duplicate IDs and caps the list at the bulk download limit.
*
* @param array<mixed> $log_ids The raw log IDs.
*
* @return array<int> The sanitized log IDs.
*/
public function sanitize_log_ids( array $log_ids ): array {
// absint() casts arrays to 1, so drop anything that isn't a scalar first.
$log_ids = array_map( 'absint', array_filter( $log_ids, 'is_scalar' ) );
$log_ids = array_values( array_unique( array_filter( $log_ids, static fn( int $id ): bool => $id > 0 ) ) );

return array_slice( $log_ids, 0, $this->get_bulk_download_limit() );
}

/**
* Gets the maximum number of log entries allowed in a single bulk download.
*
* @return int The bulk download limit.
*/
public function get_bulk_download_limit(): int {
$limit = (int) apply_filters( 'wpgraphql_logging_bulk_download_limit', self::DEFAULT_BULK_DOWNLOAD_LIMIT );
return max( 1, $limit );
}

/**
* Writes log entries as CSV rows to a stream.
*
* The header row is written once, before the first entry. Cells that a
* spreadsheet would treat as a formula (=, +, -, @, ...) are escaped, since
* log values can come from unauthenticated GraphQL requests.
*
* @param resource $stream The stream to write to.
* @param iterable $logs The log entries.
*
* @phpstan-param iterable<\WPGraphQL\Logging\Logger\Api\LogEntityInterface> $logs
*
* @return int The number of log entries written.
*/
public function write_csv( $stream, iterable $logs ): int {
$writer = Writer::from( $stream );
$writer->addFormatter( ( new EscapeFormula() )->escapeRecord( ... ) );
$count = 0;

foreach ( $logs as $log ) {
if ( 0 === $count ) {
$writer->insertOne( $this->get_headers( $log ) );
}
$writer->insertOne( $this->get_content( $log ) );
++$count;
}

return $count;
}

/**
Expand Down Expand Up @@ -109,4 +182,65 @@ public function get_content(LogEntityInterface $log): array {
];
return apply_filters( 'wpgraphql_logging_csv_content', $content, $log->get_id(), $log );
}

/**
* Verifies the current user can download logs.
*/
protected function verify_permissions(): void {
if ( ! current_user_can( 'manage_options' ) || ! is_admin() ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wpgraphql-logging' ) );
}
}

/**
* Lazily loads log entries by ID, skipping any that no longer exist.
*
* @param array<int> $log_ids The log IDs.
*
* @return \Generator<int, \WPGraphQL\Logging\Logger\Api\LogEntityInterface> The log entries.
*/
protected function get_logs( array $log_ids ): Generator {
foreach ( $log_ids as $log_id ) {
$log = $this->log_service->find_entity_by_id( $log_id );
if ( null !== $log ) {
yield $log;
}
}
}

/**
* Sends download headers and streams log entries to the browser as CSV, then exits.
*
* @param string $filename The download filename.
* @param iterable $logs The log entries.
*
* @phpstan-param iterable<\WPGraphQL\Logging\Logger\Api\LogEntityInterface> $logs
*/
protected function stream_csv( string $filename, iterable $logs ): void {
$filename = sanitize_file_name( $filename );
header( 'Content-Type: text/csv; charset=utf-8' );
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
header( 'Cache-Control: must-revalidate, post-check=0, pre-check=0' );
header( 'Expires: 0' );

// Drop any open output buffers so rows go straight to the client instead of piling up in memory.
while ( ob_get_level() > 0 ) {
$status = ob_get_status();
if ( ! isset( $status['flags'] ) || 0 === ( $status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE ) ) {
break;
}
if ( ! ob_end_clean() ) {
break;
}
}
Comment thread
Copilot marked this conversation as resolved.
Resolved

$output = fopen( 'php://output', 'w' );
if ( ! is_resource( $output ) ) {
wp_die( esc_html__( 'Failed to create CSV output.', 'wpgraphql-logging' ) );
}

$this->write_csv( $output, $logs );
fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the php://output stream, which WP_Filesystem can't handle.
exit;
}
}
42 changes: 40 additions & 2 deletions plugins/wpgraphql-logging/src/Admin/View/List/ListTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace WPGraphQL\Logging\Admin\View\List;

use DateTime;
use WPGraphQL\Logging\Admin\View\Download\DownloadLogService;
use WPGraphQL\Logging\Admin\ViewLogsPage;
use WPGraphQL\Logging\Logger\Api\LogServiceInterface;
use WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity;
Expand Down Expand Up @@ -36,6 +37,13 @@ class ListTable extends WP_List_Table {
*/
public const DEFAULT_PER_PAGE = 20;

/**
* The bulk action key for downloading the selected logs.
*
* @var string
*/
public const BULK_DOWNLOAD_ACTION = 'bulk_download';

/**
* Maximum length of the search term.
*
Expand Down Expand Up @@ -118,8 +126,9 @@ public function prepare_items(): void {
*/
public function get_bulk_actions(): array {
return [
'delete' => __( 'Delete Selected', 'wpgraphql-logging' ),
'delete_all' => __( 'Delete All', 'wpgraphql-logging' ),
self::BULK_DOWNLOAD_ACTION => __( 'Download Selected', 'wpgraphql-logging' ),
'delete' => __( 'Delete Selected', 'wpgraphql-logging' ),
'delete_all' => __( 'Delete All', 'wpgraphql-logging' ),
];
}

Expand Down Expand Up @@ -203,6 +212,35 @@ public function process_bulk_action(): void {
exit;
}

/**
* Handle the "Download Selected" bulk action.
*
* Streams the selected logs as a single CSV file and exits, so it must run
* before any HTML is output.
*
* @phpcs:disable WordPress.Security.NonceVerification.Recommended
*/
public function process_bulk_download(): void {
if ( self::BULK_DOWNLOAD_ACTION !== $this->current_action() ) {
return;
}

$nonce_value = isset( $_REQUEST['_wpnonce'] ) && is_string( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : '';
if ( false === wp_verify_nonce( $nonce_value, 'bulk-' . esc_attr( $this->_args['plural'] ) ) ) {
wp_die( esc_html__( 'Nonce verification failed!', 'wpgraphql-logging' ) );
}

$downloader = new DownloadLogService( $this->log_service );
$ids = isset( $_REQUEST['log'] ) ? $downloader->sanitize_log_ids( (array) wp_unslash( $_REQUEST['log'] ) ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize_log_ids() casts every ID with absint().

// Nothing selected: fall through and render the list as usual.
if ( [] === $ids ) {
return;
}

$downloader->generate_bulk_csv( $ids );
}

/**
* Get the columns for the logs table.
*
Expand Down
8 changes: 7 additions & 1 deletion plugins/wpgraphql-logging/src/Admin/ViewLogsPage.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,18 @@ public function render_admin_page(): void {
*/
public function process_page_actions_before_rendering(): void {

// Nonce handled in process_log_download and process_filters_redirect.
// Nonce handled in process_log_download, ListTable::process_bulk_download and process_filters_redirect.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['action'] ) && 'download' === $_GET['action'] ) {
$this->process_log_download();
}

// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_REQUEST['action'] ) && ListTable::BULK_DOWNLOAD_ACTION === $_REQUEST['action'] ) {
$list_table = new ListTable( $this->get_log_service() );
$list_table->process_bulk_download();
}

$this->process_filters_redirect();
}

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

test.describe("Bulk Log Download", () => {
test.beforeEach(async ({ admin, page }) => {
await resetPluginSettings(admin);

await goToLoggingSettingsPage(admin);
await expect(page.locator("h1")).toHaveText("WPGraphQL Logging Settings");
});

test("downloads selected logs as a single CSV", async ({
page,
admin,
request,
}) => {
await configureLogging(page, {
enabled: true,
dataSampling: "100",
eventLogSelection: ["graphql_request_results"],
});

// Create at least two log entries.
for (let i = 0; i < 2; i++) {
const response = await executeGraphQLQuery(request, GET_POSTS_QUERY);
expect(response.ok()).toBeTruthy();
}

await goToLogsListPage(admin);
await expect(page.locator("h1")).toContainText("WPGraphQL Logs");

const logRows = page
.locator("#the-list tr")
.filter({ hasText: "GetPosts" });
await expect(logRows.nth(1)).toBeVisible({ timeout: 10000 });

const selectedIds = [];
for (let i = 0; i < 2; i++) {
const checkbox = logRows.nth(i).locator('input[name="log[]"]');
await checkbox.check();
selectedIds.push(await checkbox.inputValue());
}

await page
.locator("#bulk-action-selector-top")
.selectOption("bulk_download");

const downloadPromise = page.waitForEvent("download");
await page.locator("#doaction").click();
const download = await downloadPromise;

expect(download.suggestedFilename()).toMatch(
/^graphql_logs_\d{4}-\d{2}-\d{2}_\d{6}\.csv$/
);

const fs = require("fs");
const content = fs.readFileSync(await download.path(), "utf8");
const lines = content.trim().split("\n");

// One header row, then one row per selected log.
expect(lines[0]).toContain("ID");
expect(lines[0]).toContain("Message");
for (const id of selectedIds) {
expect(content).toMatch(new RegExp(`^${id},`, "m"));
}
expect(content).toContain("GetPosts");
});
});
Loading
Loading