diff --git a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md index 8d002ccb4..661fd3119 100644 --- a/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md +++ b/docs/plugins/wpgraphql-logging/how-to/update-log-store-service/index.md @@ -104,6 +104,40 @@ 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 the dashboard + +The admin dashboard reads its numbers through `\WPGraphQL\Logging\Logger\Api\LogStatsInterface`. The built-in implementation queries the plugin's database table, so it is only used with the default log store. With a custom log store the dashboard shows a notice instead of charts. + +To enable the dashboard for your store, implement `LogStatsInterface` and return it from the `wpgraphql_logging_dashboard_stats_service` filter. All dates passed to the interface are in UTC, and each method should aggregate in your storage layer rather than loading every entry into PHP. + +```php + 0, 'errors' => 0, 'operations' => 0 ]; + } + + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array { + return array_fill( 0, $bucket_count, [ 'total' => 0, 'errors' => 0 ] ); + } + + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array { + return []; + } + + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array { + return []; + } +} + +add_filter( 'wpgraphql_logging_dashboard_stats_service', function( $stats ) { + return new MyLogStats(); +} ); +``` + ## 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`: diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index bdffa8067..1345a4821 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -51,7 +51,7 @@ The plugin provides comprehensive data management capabilities, allowing you to ### Admin UI -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. +The plugin delivers an admin UI with a search box, filtering of logs by date or level, CSV downloads, and a dashboard with charts. *** @@ -184,6 +184,40 @@ You can also filter logs with the following filters: You can download selected logs, delete selected logs, or delete all logs using the bulk action controls. +## Dashboard + +GraphQL Logs -> Dashboard summarizes the logs for the last 24 hours, 7 days (the default) or 30 days: + +* Totals for log entries, errors (level `ERROR` and above), the error rate and the number of distinct operations. +* A column chart of log entries over time, with errors stacked on top. The 24 hour range uses hourly columns; the other ranges use one column per day in the site time zone. A data table with the same numbers sits under the chart. +* Log entries grouped by level. +* The ten operations with the most log entries, with their error counts. + +The counts are log entries, not requests. One GraphQL request can write an entry for each enabled log point, and only sampled requests are logged. Operations are grouped by the `operationName` the client sends; requests without one are listed as "(unnamed)". + +The numbers are calculated in SQL and cached in a transient for 60 seconds. Change the cache lifetime, or return `0` to turn caching off, with the `wpgraphql_logging_dashboard_cache_ttl` filter: + +```php +add_filter( 'wpgraphql_logging_dashboard_cache_ttl', function( int $ttl, string $range ) { + return 5 * MINUTE_IN_SECONDS; +}, 10, 2 ); +``` + +The available ranges can be changed with the `wpgraphql_logging_dashboard_ranges` filter. Each range needs a label, a bucket size in seconds and a bucket count: + +```php +add_filter( 'wpgraphql_logging_dashboard_ranges', function( array $ranges ) { + $ranges['90d'] = [ + 'label' => __( 'Last 90 days', 'my-plugin' ), + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 90, + ]; + return $ranges; +} ); +``` + +If you [replace the log store](how-to/update-log-store-service/index.md), the dashboard shows a notice until you provide a stats service for your store. See that guide for details. + ## Uninstallation By default, WPGraphQL Logging preserves all logged data when the plugin is deactivated to prevent accidental data loss. diff --git a/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css b/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css new file mode 100644 index 000000000..d608369d6 --- /dev/null +++ b/plugins/wpgraphql-logging/assets/css/dashboard/wp-graphql-logging-dashboard.css @@ -0,0 +1,206 @@ +/* WPGraphQL Logging dashboard. Colors follow the WordPress admin palette. */ +.wpgraphql-logging-dashboard { + --wpgraphql-logging-entries: #2271b1; + --wpgraphql-logging-errors: #d63638; + --wpgraphql-logging-warnings: #dba617; + --wpgraphql-logging-grid: #dcdcde; + --wpgraphql-logging-axis: #8c8f94; + --wpgraphql-logging-text-muted: #50575e; + --wpgraphql-logging-surface: #fff; + --wpgraphql-logging-border: #c3c4c7; +} + +.wpgraphql-logging-dashboard__ranges .subsubsub { + margin-top: 12px; +} + +.wpgraphql-logging-dashboard__tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 16px; + margin: 16px 0; + padding: 0; + list-style: none; +} + +.wpgraphql-logging-dashboard__tile { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + padding: 16px; + background: var(--wpgraphql-logging-surface); + border: 1px solid var(--wpgraphql-logging-border); + border-radius: 4px; +} + +.wpgraphql-logging-dashboard__tile-label { + color: var(--wpgraphql-logging-text-muted); +} + +.wpgraphql-logging-dashboard__tile-value { + font-size: 24px; + font-weight: 600; + line-height: 1.3; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-dashboard__panel { + min-width: 0; + margin: 0 0 16px; + padding: 16px; + background: var(--wpgraphql-logging-surface); + border: 1px solid var(--wpgraphql-logging-border); + border-radius: 4px; +} + +.wpgraphql-logging-dashboard__panel h2 { + margin-top: 0; +} + +.wpgraphql-logging-dashboard__columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} + +.wpgraphql-logging-dashboard__legend { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 0 0 8px; + padding: 0; + list-style: none; +} + +.wpgraphql-logging-dashboard__legend li { + display: flex; + align-items: center; + gap: 6px; + margin: 0; +} + +.wpgraphql-logging-dashboard__swatch { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 2px; +} + +.wpgraphql-logging-dashboard__swatch--entries, +.wpgraphql-logging-dashboard__bar--entries { + background: var(--wpgraphql-logging-entries); +} + +.wpgraphql-logging-dashboard__swatch--errors, +.wpgraphql-logging-dashboard__bar--errors { + background: var(--wpgraphql-logging-errors); +} + +.wpgraphql-logging-dashboard__bar--warnings { + background: var(--wpgraphql-logging-warnings); +} + +.wpgraphql-logging-chart { + margin: 0; +} + +.wpgraphql-logging-chart__svg { + display: block; + width: 100%; + height: auto; + overflow: visible; +} + +.wpgraphql-logging-chart__gridline { + stroke: var(--wpgraphql-logging-grid); + stroke-width: 1; +} + +.wpgraphql-logging-chart__axis { + stroke: var(--wpgraphql-logging-axis); + stroke-width: 1; +} + +.wpgraphql-logging-chart__tick { + fill: var(--wpgraphql-logging-text-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-chart__hit { + fill: transparent; +} + +.wpgraphql-logging-chart__slot:hover .wpgraphql-logging-chart__hit { + fill: rgba(0, 0, 0, 0.04); +} + +.wpgraphql-logging-chart__bar--entries { + fill: var(--wpgraphql-logging-entries); +} + +.wpgraphql-logging-chart__bar--errors { + fill: var(--wpgraphql-logging-errors); +} + +.wpgraphql-logging-dashboard__data { + margin-top: 12px; +} + +.wpgraphql-logging-dashboard__data summary { + margin-bottom: 8px; + cursor: pointer; +} + +.wpgraphql-logging-dashboard .num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-dashboard__bars th[scope="row"] { + width: 40%; + overflow-wrap: anywhere; +} + +.wpgraphql-logging-dashboard__bar-cell { + display: grid; + grid-template-columns: minmax(0, 1fr) 4.5em; + align-items: center; + gap: 8px; +} + +.wpgraphql-logging-dashboard__bar-track { + display: block; +} + +.wpgraphql-logging-dashboard__bar { + display: block; + min-width: 2px; + max-width: 100%; + height: 12px; + border-radius: 0 4px 4px 0; +} + +.wpgraphql-logging-dashboard__bar-value { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.wpgraphql-logging-dashboard__note { + margin-top: 8px; +} + +/* Windows High Contrast and other forced-color modes: keep marks visible; the legend and data table carry identity. */ +@media (forced-colors: active) { + .wpgraphql-logging-chart__bar--entries, + .wpgraphql-logging-chart__bar--errors { + fill: CanvasText; + } + + .wpgraphql-logging-dashboard__bar, + .wpgraphql-logging-dashboard__swatch { + forced-color-adjust: none; + background: CanvasText; + } +} diff --git a/plugins/wpgraphql-logging/readme.txt b/plugins/wpgraphql-logging/readme.txt index ecec020bd..d617297d0 100644 --- a/plugins/wpgraphql-logging/readme.txt +++ b/plugins/wpgraphql-logging/readme.txt @@ -25,6 +25,7 @@ WPGraphQL Logging plugin provides observability and visibility into the GraphQL **Advanced Admin Interface** * View and filter all logged GraphQL requests in the WordPress admin +* Dashboard with charts of log entries over time, entries by level and the top operations * Search and sort by date, query name, log level, or custom criteria * Export logs to CSV for offline analysis * Configurable data retention policies with automatic cleanup diff --git a/plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php b/plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php new file mode 100644 index 000000000..cd0de80eb --- /dev/null +++ b/plugins/wpgraphql-logging/src/Admin/Dashboard/TimeSeriesChart.php @@ -0,0 +1,284 @@ + for hover, and the dashboard template pairs the chart with a data table. + * + * @package WPGraphQL\Logging + * + * @since 1.1.0 + */ +class TimeSeriesChart { + /** + * SVG viewBox width. + * + * @var int + */ + public const WIDTH = 720; + + /** + * SVG viewBox height. + * + * @var int + */ + public const HEIGHT = 240; + + /** + * Plot margins: top, right, bottom, left. + * + * @var array + */ + protected const MARGIN = [ 12.0, 8.0, 28.0, 48.0 ]; + + /** + * Maximum column width so a short range (e.g. 7 days) does not draw slabs. + * + * @var float + */ + protected const MAX_BAR_WIDTH = 40.0; + + /** + * Gap in pixels between stacked segments. + * + * @var float + */ + protected const SEGMENT_GAP = 2.0; + + /** + * Corner radius for the top (data end) of each column. + * + * @var float + */ + protected const RADIUS = 4.0; + + /** + * Outputs the chart SVG. + * + * @param array $points The data points, oldest first. + * @param string $id Unique ID prefix for the SVG title/description. + * @param string $title Accessible chart title. + * @param string $description Accessible chart description. + */ + public function render( array $points, string $id, string $title, string $description ): void { + $points = array_values( $points ); + $max_value = 0; + foreach ( $points as $point ) { + $max_value = max( $max_value, $point['total'] ); + } + [ $axis_max, $step ] = self::nice_scale( $max_value ); + + printf( + '', + esc_attr( self::num( self::WIDTH ) ), + esc_attr( self::num( self::HEIGHT ) ), + esc_attr( $id ) + ); + printf( '%2$s', esc_attr( $id ), esc_html( $title ) ); + printf( '%2$s', esc_attr( $id ), esc_html( $description ) ); + + $this->render_grid( $axis_max, $step ); + + $count = max( 1, count( $points ) ); + $label_step = (int) max( 1, ceil( $count / 8 ) ); + foreach ( $points as $index => $point ) { + $this->render_column( $point, $index, $count, $axis_max, 0 === $index % $label_step ); + } + + echo ''; + } + + /** + * Computes a "nice" axis maximum and tick step for a maximum data value. + * + * @param int $max_value The largest value to plot. + * + * @return array{0: int, 1: int} The axis maximum and the tick step. + */ + public static function nice_scale( int $max_value ): array { + if ( $max_value <= 0 ) { + return [ 4, 1 ]; + } + + $raw_step = max( 1.0, (float) $max_value / 4.0 ); + $magnitude = 10.0 ** floor( log10( $raw_step ) ); + $residual = $raw_step / $magnitude; + + // Pick the smallest of 1, 2, 5 or 10 (times the magnitude) that covers the raw step. + $nice = 10.0; + foreach ( [ 1.0, 2.0, 5.0 ] as $candidate ) { + if ( $residual <= $candidate ) { + $nice = $candidate; + break; + } + } + + $step = max( 1, (int) round( $nice * $magnitude ) ); + + return [ (int) ceil( $max_value / $step ) * $step, $step ]; + } + + /** + * Outputs recessive horizontal grid lines and y-axis labels. + * + * @param int $axis_max The axis maximum. + * @param int $step The tick step. + */ + protected function render_grid( int $axis_max, int $step ): void { + $plot = self::get_plot_area(); + + echo ''; + } + + /** + * Outputs one stacked column: non-error entries at the baseline, errors on top. + * + * @param array{label: string, full_label: string, total: int, errors: int} $point The data point. + * @param int $index The column index. + * @param int $count The number of columns. + * @param int $axis_max The axis maximum. + * @param bool $show_label Whether to draw the x-axis label. + */ + protected function render_column( array $point, int $index, int $count, int $axis_max, bool $show_label ): void { + $plot = self::get_plot_area(); + $slot_width = $plot['width'] / (float) $count; + $bar_width = min( self::MAX_BAR_WIDTH, max( 2.0, $slot_width - max( 2.0, $slot_width * 0.3 ) ) ); + $slot_x = $plot['left'] + ( (float) $index * $slot_width ); + $bar_x = $slot_x + ( ( $slot_width - $bar_width ) / 2.0 ); + $errors = max( 0, $point['errors'] ); + $others = max( 0, $point['total'] - $errors ); + $scale = $plot['height'] / (float) $axis_max; + + echo ''; + printf( + '%s', + esc_html( + sprintf( + /* translators: 1: date/time label, 2: number of log entries, 3: number of error entries. */ + __( '%1$s: %2$s entries, %3$s errors', 'wpgraphql-logging' ), + $point['full_label'], + number_format_i18n( $point['total'] ), + number_format_i18n( $errors ) + ) + ) + ); + + // Hit target covers the full slot so small columns are still easy to hover. + printf( + '', + esc_attr( self::num( $slot_x ) ), + esc_attr( self::num( $plot['top'] ) ), + esc_attr( self::num( $slot_width ) ), + esc_attr( self::num( $plot['height'] ) ) + ); + + $others_height = (float) $others * $scale; + if ( $others > 0 ) { + $this->render_segment( 'wpgraphql-logging-chart__bar--entries', $bar_x, $plot['baseline'] - $others_height, $bar_width, $others_height, 0 === $errors ); + } + if ( $errors > 0 ) { + $gap = $others > 0 ? self::SEGMENT_GAP : 0.0; + $errors_height = max( 1.0, ( (float) $errors * $scale ) - $gap ); + $this->render_segment( 'wpgraphql-logging-chart__bar--errors', $bar_x, $plot['baseline'] - $others_height - $gap - $errors_height, $bar_width, $errors_height, true ); + } + + if ( $show_label ) { + printf( + '', + esc_attr( self::num( $slot_x + ( $slot_width / 2.0 ) ) ), + esc_attr( self::num( $plot['baseline'] + 18.0 ) ), + esc_html( $point['label'] ) + ); + } + echo ''; + } + + /** + * Outputs one column segment. The top segment gets rounded top corners; the baseline stays square. + * + * @param string $css_class The segment CSS class. + * @param float $x Left edge. + * @param float $y Top edge. + * @param float $width Segment width. + * @param float $height Segment height. + * @param bool $is_top Whether this is the topmost segment of the column. + */ + protected function render_segment( string $css_class, float $x, float $y, float $width, float $height, bool $is_top ): void { + $radius = $is_top ? min( self::RADIUS, $width / 2.0, $height ) : 0.0; + + if ( $radius <= 0 ) { + printf( + '', + esc_attr( $css_class ), + esc_attr( self::num( $x ) ), + esc_attr( self::num( $y ) ), + esc_attr( self::num( $width ) ), + esc_attr( self::num( $height ) ) + ); + return; + } + + $path = sprintf( + 'M%1$s %2$s V%3$s Q%1$s %4$s %5$s %4$s H%6$s Q%7$s %4$s %7$s %3$s V%2$s Z', + self::num( $x ), + self::num( $y + $height ), + self::num( $y + $radius ), + self::num( $y ), + self::num( $x + $radius ), + self::num( $x + $width - $radius ), + self::num( $x + $width ) + ); + printf( '', esc_attr( $css_class ), esc_attr( $path ) ); + } + + /** + * Gets the plot area geometry inside the margins. + * + * @return array{top: float, left: float, width: float, height: float, baseline: float} + */ + protected static function get_plot_area(): array { + [ $top, $right, $bottom, $left ] = self::MARGIN; + + $height = (float) self::HEIGHT - $top - $bottom; + + return [ + 'top' => $top, + 'left' => $left, + 'width' => (float) self::WIDTH - $left - $right, + 'height' => $height, + 'baseline' => $top + $height, + ]; + } + + /** + * Formats a coordinate with at most two decimals using a locale-independent decimal point. + * + * @param float|int $value The value to format. + */ + protected static function num( float|int $value ): string { + $formatted = rtrim( rtrim( number_format( (float) $value, 2, '.', '' ), '0' ), '.' ); + return ( '' === $formatted || '-0' === $formatted ) ? '0' : $formatted; + } +} diff --git a/plugins/wpgraphql-logging/src/Admin/DashboardPage.php b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php new file mode 100644 index 000000000..52f42391a --- /dev/null +++ b/plugins/wpgraphql-logging/src/Admin/DashboardPage.php @@ -0,0 +1,557 @@ +setup(); + } + + /** + * Fires after the dashboard page is initialized. + * + * @param \WPGraphQL\Logging\Admin\DashboardPage $instance The dashboard page instance. + */ + do_action( 'wpgraphql_logging_dashboard_init', self::$instance ); + + return self::$instance; + } + + /** + * Sets up the dashboard page hooks. + */ + public function setup(): void { + // Priority 10 runs after the logs menu is registered (same priority, registered earlier) and before Settings (11). + add_action( 'admin_menu', [ $this, 'register_page' ], 10, 0 ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_styles' ], 10, 1 ); + } + + /** + * Registers the dashboard submenu page. + */ + public function register_page(): void { + $page_hook = add_submenu_page( + ViewLogsPage::ADMIN_PAGE_SLUG, + esc_html__( 'WPGraphQL Logging Dashboard', 'wpgraphql-logging' ), + esc_html__( 'Dashboard', 'wpgraphql-logging' ), + 'manage_options', + self::ADMIN_PAGE_SLUG, + [ $this, 'render_page' ] + ); + + $this->page_hook = is_string( $page_hook ) ? $page_hook : ''; + } + + /** + * Enqueues the dashboard stylesheet on the dashboard page only. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_admin_styles( string $hook_suffix ): void { + if ( '' === $this->page_hook || $hook_suffix !== $this->page_hook ) { + return; + } + + $relative_path = 'assets/css/dashboard/wp-graphql-logging-dashboard.css'; + if ( file_exists( trailingslashit( WPGRAPHQL_LOGGING_PLUGIN_DIR ) . $relative_path ) ) { + wp_enqueue_style( + 'wpgraphql-logging-dashboard', + trailingslashit( WPGRAPHQL_LOGGING_PLUGIN_URL ) . $relative_path, + [], + WPGRAPHQL_LOGGING_VERSION + ); + } + + /** + * Fires after the dashboard styles are enqueued. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + do_action( 'wpgraphql_logging_dashboard_admin_enqueue_scripts', $hook_suffix ); + } + + /** + * Gets the available time ranges. + * + * @return array Ranges keyed by slug. + */ + public function get_ranges(): array { + $ranges = [ + '24h' => [ + 'label' => __( 'Last 24 hours', 'wpgraphql-logging' ), + 'bucket_seconds' => HOUR_IN_SECONDS, + 'bucket_count' => 24, + ], + '7d' => [ + 'label' => __( 'Last 7 days', 'wpgraphql-logging' ), + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 7, + ], + '30d' => [ + 'label' => __( 'Last 30 days', 'wpgraphql-logging' ), + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 30, + ], + ]; + + /** + * Filters the dashboard time ranges. + * + * @param array $ranges Ranges keyed by slug. + */ + $filtered = apply_filters( 'wpgraphql_logging_dashboard_ranges', $ranges ); + if ( ! is_array( $filtered ) ) { + return $ranges; + } + + // Drop malformed ranges added by filters. + $valid = []; + foreach ( $filtered as $slug => $range ) { + if ( ! is_string( $slug ) || ! $this->is_valid_range( $slug, $range ) ) { + continue; + } + /** @var array{label: string, bucket_seconds: int, bucket_count: int} $range */ + $valid[ $slug ] = [ + 'label' => $range['label'], + 'bucket_seconds' => $range['bucket_seconds'], + 'bucket_count' => $range['bucket_count'], + ]; + } + + return empty( $valid ) ? $ranges : $valid; + } + + /** + * Gets the selected time range slug from the request, falling back to the default. + * + * This is a read-only view, so no nonce is required; the value is validated against an allow list. + */ + public function get_current_range(): string { + $ranges = $this->get_ranges(); + $default = array_key_exists( self::DEFAULT_RANGE, $ranges ) ? self::DEFAULT_RANGE : (string) array_key_first( $ranges ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['range'] ) || ! is_string( $_GET['range'] ) ) { + return $default; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $range = sanitize_key( wp_unslash( $_GET['range'] ) ); + + return array_key_exists( $range, $ranges ) ? $range : $default; + } + + /** + * Computes the window and bucket boundaries for a range, aligned to the site time zone. + * + * Hourly ranges start at the top of a local hour. Daily ranges use local calendar days, + * so a day can be 23 or 25 hours long when daylight saving time changes. The last bucket + * always contains "now". + * + * @param array{label: string, bucket_seconds: int, bucket_count: int} $range The range definition. + * @param \DateTimeImmutable|null $now The current time (for testing). + * + * @return array{start: int, end: int, bucket_starts: array} Unix timestamps. + */ + public function get_window( array $range, ?DateTimeImmutable $now = null ): array { + $now = ( $now ?? new DateTimeImmutable( 'now' ) )->setTimezone( wp_timezone() ); + $bucket_seconds = max( 1, $range['bucket_seconds'] ); + $bucket_count = max( 1, $range['bucket_count'] ); + $bucket_starts = []; + + if ( DAY_IN_SECONDS === $bucket_seconds ) { + $day = $now->modify( sprintf( '-%d days', $bucket_count - 1 ) )->setTime( 0, 0 ); + for ( $i = 0; $i < $bucket_count; $i++ ) { + $bucket_starts[] = $day->getTimestamp(); + $day = $day->modify( '+1 day' )->setTime( 0, 0 ); + } + + return [ + 'start' => $bucket_starts[0], + 'end' => $day->getTimestamp(), + 'bucket_starts' => $bucket_starts, + ]; + } + + $aligned = HOUR_IN_SECONDS === $bucket_seconds + ? $now->setTime( (int) $now->format( 'G' ), 0 )->getTimestamp() + : $now->getTimestamp() - ( $now->getTimestamp() % $bucket_seconds ); + $start = $aligned - ( $bucket_seconds * ( $bucket_count - 1 ) ); + + for ( $i = 0; $i < $bucket_count; $i++ ) { + $bucket_starts[] = $start + ( $i * $bucket_seconds ); + } + + return [ + 'start' => $start, + 'end' => $start + ( $bucket_seconds * $bucket_count ), + 'bucket_starts' => $bucket_starts, + ]; + } + + /** + * Gets the stats service used to aggregate log data. + * + * Returns null when the log store has been replaced and no stats service was provided for it. + */ + public function get_stats_service(): ?LogStatsInterface { + $default = LogStoreService::get_log_service() instanceof WordPressDatabaseLogService + ? new WordPressDatabaseLogStats() + : null; + + /** + * Filters the stats service used by the dashboard. + * + * Return an implementation of LogStatsInterface to support a custom log store. + * + * @param \WPGraphQL\Logging\Logger\Api\LogStatsInterface|null $default The default stats service. + */ + $service = apply_filters( 'wpgraphql_logging_dashboard_stats_service', $default ); + + return $service instanceof LogStatsInterface ? $service : null; + } + + /** + * Gets the aggregated dashboard data for a range, using a short-lived transient cache. + * + * Only raw numbers and timestamps are cached; labels are formatted per request so each + * user sees dates in their own locale. + * + * @param string $range The range slug. + * + * @return array{range: string, start: int, end: int, bucket_starts: array, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null + */ + public function get_dashboard_data( string $range ): ?array { + $ranges = $this->get_ranges(); + if ( ! array_key_exists( $range, $ranges ) ) { + return null; + } + + $stats = $this->get_stats_service(); + if ( null === $stats ) { + return null; + } + + $cache_key = self::CACHE_KEY_PREFIX . $range; + /** + * Filters how long dashboard data is cached, in seconds. Return 0 to disable caching. + * + * @param int $ttl Cache lifetime in seconds. + * @param string $range The range slug. + */ + $ttl = (int) apply_filters( 'wpgraphql_logging_dashboard_cache_ttl', self::CACHE_TTL, $range ); + if ( $ttl > 0 ) { + $cached = get_transient( $cache_key ); + if ( is_array( $cached ) && isset( $cached['range'] ) && $range === $cached['range'] ) { + /** @var array{range: string, start: int, end: int, bucket_starts: array, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array} $cached */ + return $cached; + } + } + + $window = $this->get_window( $ranges[ $range ] ); + $start = new DateTimeImmutable( '@' . $window['start'] ); + $end = new DateTimeImmutable( '@' . $window['end'] ); + + $data = [ + 'range' => $range, + 'start' => $window['start'], + 'end' => $window['end'], + 'bucket_starts' => $window['bucket_starts'], + 'summary' => $stats->get_summary( $start, $end ), + 'series' => $this->get_series( $stats, $window, $ranges[ $range ]['bucket_seconds'] ), + 'levels' => $stats->get_level_breakdown( $start, $end ), + 'operations' => $stats->get_top_operations( $start, $end, self::TOP_OPERATIONS_LIMIT ), + ]; + + if ( $ttl > 0 ) { + set_transient( $cache_key, $data, $ttl ); + } + + return $data; + } + + /** + * Renders the dashboard page. + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wpgraphql-logging' ) ); + } + + $ranges = $this->get_ranges(); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $current_range = $this->get_current_range(); + $data = $this->get_dashboard_data( $current_range ); + $series = null === $data ? [] : $this->label_series( $data['series'], $data['bucket_starts'], $ranges[ $current_range ]['bucket_seconds'] ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $chart = new TimeSeriesChart(); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $base_url = menu_page_url( self::ADMIN_PAGE_SLUG, false ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + $logs_url = menu_page_url( ViewLogsPage::ADMIN_PAGE_SLUG, false ); // @phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable + + $template = (string) apply_filters( 'wpgraphql_logging_dashboard_template_path', __DIR__ . '/View/Templates/WPGraphQLLoggerDashboard.php' ); + if ( ! file_exists( $template ) ) { + return; + } + + require $template; // @phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.UsingVariable + } + + /** + * Adds display labels to the bucket counts. + * + * @param array $series Bucket counts. + * @param array $bucket_starts Bucket start timestamps. + * @param int $bucket_seconds The nominal bucket size. + * + * @return array + */ + public function label_series( array $series, array $bucket_starts, int $bucket_seconds ): array { + $daily = $bucket_seconds >= DAY_IN_SECONDS; + $date_format = (string) get_option( 'date_format', 'F j, Y' ); + $time_format = (string) get_option( 'time_format', 'g:i a' ); + /* translators: Short date format for chart axis labels, see https://www.php.net/manual/datetime.format.php */ + $short_date = __( 'M j', 'wpgraphql-logging' ); + $labelled = []; + + foreach ( array_values( $bucket_starts ) as $index => $timestamp ) { + $counts = $series[ $index ] ?? [ + 'total' => 0, + 'errors' => 0, + ]; + $labelled[] = [ + 'label' => (string) wp_date( $daily ? $short_date : $time_format, $timestamp ), + 'full_label' => (string) wp_date( $daily ? $date_format : $date_format . ' ' . $time_format, $timestamp ), + 'total' => $counts['total'], + 'errors' => $counts['errors'], + ]; + } + + return $labelled; + } + + /** + * Returns a part of a whole as a percentage, or 0 when the whole is empty. + * + * @param int $part The part. + * @param int $whole The whole. + */ + public static function percent( int $part, int $whole ): float { + if ( $whole <= 0 ) { + return 0.0; + } + + return ( (float) $part / (float) $whole ) * 100.0; + } + + /** + * Shortens a label for display, keeping multibyte characters intact. + * + * @param string $text The text to shorten. + * @param int $max_length Maximum number of characters before an ellipsis is added. + */ + public static function truncate( string $text, int $max_length = 80 ): string { + if ( mb_strlen( $text ) <= $max_length ) { + return $text; + } + + return mb_substr( $text, 0, $max_length ) . "\u{2026}"; + } + + /** + * Maps a Monolog level to the CSS modifier used for its bar: errors, warnings or entries. + * + * @param int $level The Monolog level. + */ + public static function get_level_modifier( int $level ): string { + if ( $level >= WordPressDatabaseLogStats::ERROR_LEVEL ) { + return 'errors'; + } + + return $level >= self::WARNING_LEVEL ? 'warnings' : 'entries'; + } + + /** + * Sums fixed-size source buckets into (possibly uneven) target buckets. + * + * @param array $source Source counts keyed by index. + * @param int $source_start Start of source bucket 0 (Unix timestamp). + * @param int $source_seconds Size of each source bucket in seconds. + * @param array $bucket_starts Ascending start timestamps of the target buckets. + * + * @return array Target counts, one per bucket start. + */ + public static function fold_series( array $source, int $source_start, int $source_seconds, array $bucket_starts ): array { + $bucket_starts = array_values( $bucket_starts ); + $count = count( $bucket_starts ); + if ( 0 === $count ) { + return []; + } + + $totals = array_fill( 0, $count, 0 ); + $errors = array_fill( 0, $count, 0 ); + + ksort( $source ); + $bucket = 0; + foreach ( $source as $index => $counts ) { + $timestamp = $source_start + ( $index * $source_seconds ); + while ( $bucket + 1 < $count && $timestamp >= $bucket_starts[ $bucket + 1 ] ) { + ++$bucket; + } + $totals[ $bucket ] += $counts['total']; + $errors[ $bucket ] += $counts['errors']; + } + + return array_map( + static fn ( int $total, int $error_count ): array => [ + 'total' => $total, + 'errors' => $error_count, + ], + $totals, + $errors + ); + } + + /** + * Checks that a filtered range has a key-safe slug, a label and positive integer bucket settings. + * + * @param string $slug The range slug. + * @param mixed $range The range definition. + */ + protected function is_valid_range( string $slug, $range ): bool { + if ( sanitize_key( $slug ) !== $slug || ! is_array( $range ) ) { + return false; + } + + $label = $range['label'] ?? null; + $bucket_seconds = $range['bucket_seconds'] ?? null; + $bucket_count = $range['bucket_count'] ?? null; + + return is_string( $label ) && is_int( $bucket_seconds ) && is_int( $bucket_count ) && $bucket_seconds > 0 && $bucket_count > 0; + } + + /** + * Gets the counts for each bucket in a window. + * + * Daily buckets follow local calendar days, which are not always 24 hours long, so they + * are built from fixed-size source buckets aligned with each calendar-day boundary. + * + * @param \WPGraphQL\Logging\Logger\Api\LogStatsInterface $stats The stats service. + * @param array{start: int, end: int, bucket_starts: array} $window The window. + * @param int $bucket_seconds The nominal bucket size. + * + * @return array + */ + protected function get_series( LogStatsInterface $stats, array $window, int $bucket_seconds ): array { + $start = new DateTimeImmutable( '@' . $window['start'] ); + + if ( DAY_IN_SECONDS !== $bucket_seconds ) { + return array_values( $stats->get_time_series( $start, $bucket_seconds, count( $window['bucket_starts'] ) ) ); + } + + $boundaries = array_merge( $window['bucket_starts'], [ $window['end'] ] ); + $source_seconds = 0; + for ( $i = 1, $count = count( $boundaries ); $i < $count; $i++ ) { + $source_seconds = self::greatest_common_divisor( $source_seconds, $boundaries[ $i ] - $boundaries[ $i - 1 ] ); + } + $source_seconds = max( 1, $source_seconds ); + $source_count = (int) ceil( ( $window['end'] - $window['start'] ) / $source_seconds ); + $source = $stats->get_time_series( $start, $source_seconds, max( 1, $source_count ) ); + + return self::fold_series( $source, $window['start'], $source_seconds, $window['bucket_starts'] ); + } + + /** + * Gets the greatest common divisor of two non-negative integers. + */ + private static function greatest_common_divisor( int $a, int $b ): int { + while ( 0 !== $b ) { + $remainder = $a % $b; + $a = $b; + $b = $remainder; + } + + return $a; + } +} diff --git a/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php new file mode 100644 index 000000000..c0ad04e5f --- /dev/null +++ b/plugins/wpgraphql-logging/src/Admin/View/Templates/WPGraphQLLoggerDashboard.php @@ -0,0 +1,214 @@ + $ranges + * @var string $current_range + * @var array{range: string, start: int, end: int, bucket_starts: array, summary: array{total: int, errors: int, operations: int}, series: array, levels: array, operations: array}|null $data + * @var array $series + * @var \WPGraphQL\Logging\Admin\Dashboard\TimeSeriesChart $chart + * @var string $base_url + * @var string $logs_url + * + * @since 1.1.0 + * + * @phpcs:disable HWPStandard.ControlStructures.ElseKeyword + */ + + +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + +$wpgraphql_logging_range_label = $ranges[ $current_range ]['label'] ?? ''; +?> +
+

+ +
+ + +
+ + +
+

+
+ + number_format_i18n( $wpgraphql_logging_summary['total'] ), + __( 'Errors', 'wpgraphql-logging' ) => number_format_i18n( $wpgraphql_logging_summary['errors'] ), + /* translators: %s: error rate as a percentage. */ + __( 'Error rate', 'wpgraphql-logging' ) => sprintf( __( '%s%%', 'wpgraphql-logging' ), number_format_i18n( DashboardPage::percent( $wpgraphql_logging_summary['errors'], $wpgraphql_logging_summary['total'] ), 1 ) ), + __( 'Distinct operations', 'wpgraphql-logging' ) => number_format_i18n( $wpgraphql_logging_summary['operations'] ), + ]; + + // Outputs a table cell with a proportional bar and its value. + $wpgraphql_logging_bar_cell = static function ( int $value, int $max, string $modifier ): void { + ?> + + + + + + + +
    + $wpgraphql_logging_tile_value ) : ?> +
  • + + +
  • + +
+ + +
+

+
+ + +
+

+
    +
  • +
  • +
+
+ render( + $series, + 'wpgraphql-logging-entries-chart', + /* translators: %s: time range label, e.g. "Last 7 days". */ + sprintf( __( 'Log entries over time, %s', 'wpgraphql-logging' ), $wpgraphql_logging_range_label ), + sprintf( + /* translators: 1: total number of log entries, 2: number of error entries. */ + __( 'Stacked column chart. %1$s entries in total, of which %2$s are errors. A data table follows.', 'wpgraphql-logging' ), + number_format_i18n( $wpgraphql_logging_summary['total'] ), + number_format_i18n( $wpgraphql_logging_summary['errors'] ) + ) + ); + ?> +
+
+ + + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+

+ +

+ + + + + + + + + + + + + + + + + +
+ +
+ +
+

+ +

+ + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
+ +

+ +

+ +
diff --git a/plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php b/plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php new file mode 100644 index 000000000..fd6e2b3da --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Api/LogStatsInterface.php @@ -0,0 +1,65 @@ + Counts keyed by bucket index (0 to $bucket_count - 1). + */ + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array; + + /** + * Gets log entry counts grouped by log level. + * + * @param \DateTimeInterface $start Start of the window (inclusive, UTC). + * @param \DateTimeInterface $end End of the window (exclusive, UTC). + * + * @return array Rows ordered by level, highest first. + */ + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array; + + /** + * Gets the GraphQL operations with the most log entries. + * + * Entries without an operation name are grouped under an empty string. + * + * @param \DateTimeInterface $start Start of the window (inclusive, UTC). + * @param \DateTimeInterface $end End of the window (exclusive, UTC). + * @param int $limit Maximum number of operations to return. + * + * @return array Rows ordered by total, highest first. + */ + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array; +} diff --git a/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php new file mode 100644 index 000000000..d27bfa3b6 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Database/WordPressDatabaseLogStats.php @@ -0,0 +1,191 @@ += %d THEN 1 ELSE 0 END) AS errors,' + . ' COUNT(DISTINCT NULLIF(' . self::OPERATION_SQL . ", '')) AS operations" + . ' FROM %i WHERE datetime >= %s AND datetime < %s'; + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The only interpolated value is a class constant. + $row = $wpdb->get_row( $wpdb->prepare( $sql, self::ERROR_LEVEL, $this->get_table_name(), $this->format_date( $start ), $this->format_date( $end ) ), ARRAY_A ); + + return [ + 'total' => (int) ( $row['total'] ?? 0 ), + 'errors' => (int) ( $row['errors'] ?? 0 ), + 'operations' => (int) ( $row['operations'] ?? 0 ), + ]; + } + + /** + * {@inheritDoc} + */ + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array { + global $wpdb; + + $bucket_seconds = max( 1, $bucket_seconds ); + $bucket_count = max( 1, $bucket_count ); + $start_date = $this->format_date( $start ); + $end_date = gmdate( 'Y-m-d H:i:s', $start->getTimestamp() + ( $bucket_seconds * $bucket_count ) ); + + // TIMESTAMPDIFF between two naive DATETIME values is independent of the MySQL session time zone. + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT FLOOR(TIMESTAMPDIFF(SECOND, %s, datetime) / %d) AS bucket,' + . ' COUNT(*) AS total,' + . ' SUM(CASE WHEN level >= %d THEN 1 ELSE 0 END) AS errors' + . ' FROM %i WHERE datetime >= %s AND datetime < %s' + . ' GROUP BY bucket ORDER BY bucket', + $start_date, + $bucket_seconds, + self::ERROR_LEVEL, + $this->get_table_name(), + $start_date, + $end_date + ), + ARRAY_A + ); + + $series = array_fill( 0, $bucket_count, [ + 'total' => 0, + 'errors' => 0, + ] ); + + if ( ! is_array( $results ) ) { + return $series; + } + + foreach ( $results as $row ) { + $bucket = (int) $row['bucket']; + if ( $bucket < 0 || $bucket >= $bucket_count ) { + continue; + } + $series[ $bucket ] = [ + 'total' => (int) $row['total'], + 'errors' => (int) $row['errors'], + ]; + } + + return $series; + } + + /** + * {@inheritDoc} + */ + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array { + global $wpdb; + + $results = $wpdb->get_results( + $wpdb->prepare( + 'SELECT level, level_name, COUNT(*) AS total FROM %i' + . ' WHERE datetime >= %s AND datetime < %s' + . ' GROUP BY level, level_name ORDER BY level DESC', + $this->get_table_name(), + $this->format_date( $start ), + $this->format_date( $end ) + ), + ARRAY_A + ); + + if ( ! is_array( $results ) ) { + return []; + } + + return array_map( + static fn ( array $row ): array => [ + 'level' => (int) $row['level'], + 'level_name' => (string) $row['level_name'], + 'total' => (int) $row['total'], + ], + $results + ); + } + + /** + * {@inheritDoc} + */ + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array { + global $wpdb; + + $sql = 'SELECT ' . self::OPERATION_SQL . ' AS operation,' + . ' COUNT(*) AS total,' + . ' SUM(CASE WHEN level >= %d THEN 1 ELSE 0 END) AS errors' + . ' FROM %i WHERE datetime >= %s AND datetime < %s' + . ' GROUP BY operation ORDER BY total DESC, operation ASC LIMIT %d'; + + $results = $wpdb->get_results( + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The only interpolated value is a class constant. + $wpdb->prepare( $sql, self::ERROR_LEVEL, $this->get_table_name(), $this->format_date( $start ), $this->format_date( $end ), max( 1, $limit ) ), + ARRAY_A + ); + + if ( ! is_array( $results ) ) { + return []; + } + + return array_map( + static fn ( array $row ): array => [ + 'operation' => (string) $row['operation'], + 'total' => (int) $row['total'], + 'errors' => (int) $row['errors'], + ], + $results + ); + } + + /** + * Gets the log table name. + */ + protected function get_table_name(): string { + return WordPressDatabaseEntity::get_table_name(); + } + + /** + * Formats a date as a UTC MySQL DATETIME string. + * + * @param \DateTimeInterface $date The date to format. + */ + protected function format_date( DateTimeInterface $date ): string { + return gmdate( 'Y-m-d H:i:s', $date->getTimestamp() ); + } +} diff --git a/plugins/wpgraphql-logging/src/Plugin.php b/plugins/wpgraphql-logging/src/Plugin.php index 298642a08..4c0313555 100644 --- a/plugins/wpgraphql-logging/src/Plugin.php +++ b/plugins/wpgraphql-logging/src/Plugin.php @@ -5,6 +5,7 @@ namespace WPGraphQL\Logging; use WPGraphQL\Logging\Admin\AdminNotice; +use WPGraphQL\Logging\Admin\DashboardPage; use WPGraphQL\Logging\Admin\Settings\ConfigurationHelper; use WPGraphQL\Logging\Admin\Settings\Fields\Tab\BasicConfigurationTab; use WPGraphQL\Logging\Admin\Settings\Fields\Tab\DataManagementTab; @@ -68,6 +69,7 @@ public function setup(): void { ConfigurationHelper::register_cache_hooks(); // Register cache hooks. SettingsPage::init(); // Settings page. ViewLogsPage::init(); // View logs page. + DashboardPage::init(); // Dashboard page. AdminNotice::init(); // Admin notices. } diff --git a/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php b/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php index aca0a2554..5f60e7bf7 100644 --- a/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php +++ b/plugins/wpgraphql-logging/tests/e2e/plugins/reset-wpgraphql-logging-settings/reset-wpgraphql-logging-settings.php @@ -15,6 +15,11 @@ $table_name = $wpdb->prefix . 'wpgraphql_logging'; $wpdb->query("TRUNCATE TABLE {$table_name}"); + // Clear cached dashboard figures + foreach (array('24h', '7d', '30d') as $range) { + delete_transient('wpgraphql_logging_dashboard_' . $range); + } + // Remove admin notice dismissed meta data delete_user_meta(get_current_user_id(), 'wpgraphql-logging-admin-notice'); diff --git a/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js b/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js new file mode 100644 index 000000000..dd1d724db --- /dev/null +++ b/plugins/wpgraphql-logging/tests/e2e/specs/dashboard.spec.js @@ -0,0 +1,73 @@ +import { expect, test } from "@wordpress/e2e-test-utils-playwright"; +import { + goToLoggingSettingsPage, + goToDashboardPage, + configureLogging, + resetPluginSettings, +} from "../utils"; +import { GET_POSTS_QUERY } from "../constants"; + +test.describe("Logging Dashboard", () => { + test.beforeEach(async ({ admin }) => { + await resetPluginSettings(admin); + }); + + test("is listed in the GraphQL Logs menu", async ({ page, admin }) => { + await admin.visitAdminPage("/index.php"); + const menuLink = page.locator( + '#adminmenu a[href="admin.php?page=wpgraphql-logging-dashboard"]' + ); + await expect(menuLink).toHaveText("Dashboard"); + }); + + test("loads with summary tiles, chart and range links", async ({ + page, + admin, + }) => { + await goToDashboardPage(admin); + + await expect(page.locator("h1")).toHaveText("WPGraphQL Logging Dashboard"); + await expect(page.locator(".wpgraphql-logging-dashboard__tile")).toHaveCount(4); + await expect( + page.locator('svg.wpgraphql-logging-chart__svg[role="img"]') + ).toBeVisible(); + + // Default range is the last 7 days. + await expect( + page.locator('.wpgraphql-logging-dashboard__ranges a[aria-current="page"]') + ).toHaveText("Last 7 days"); + + await page + .locator(".wpgraphql-logging-dashboard__ranges a") + .filter({ hasText: "Last 24 hours" }) + .click(); + await expect(page).toHaveURL(/range=24h/); + await expect( + page.locator('.wpgraphql-logging-dashboard__ranges a[aria-current="page"]') + ).toHaveText("Last 24 hours"); + }); + + test("shows logged operations", async ({ page, admin, request }) => { + await goToLoggingSettingsPage(admin); + await configureLogging(page, { + enabled: true, + dataSampling: "100", + eventLogSelection: ["graphql_request_results"], + }); + + // The dashboard groups by the operationName the client sends, so include it. + const response = await request.post("/graphql", { + data: { query: GET_POSTS_QUERY, operationName: "GetPosts" }, + headers: { "Content-Type": "application/json" }, + }); + expect(response.ok()).toBeTruthy(); + + // The reset helper clears cached dashboard figures, so the new entry shows up straight away. + await goToDashboardPage(admin); + + const operationsPanel = page.locator( + 'section[aria-labelledby="wpgraphql-logging-operations-heading"]' + ); + await expect(operationsPanel.locator("code", { hasText: "GetPosts" })).toBeVisible(); + }); +}); diff --git a/plugins/wpgraphql-logging/tests/e2e/utils.js b/plugins/wpgraphql-logging/tests/e2e/utils.js index b7c3e12d1..259f2e0d6 100644 --- a/plugins/wpgraphql-logging/tests/e2e/utils.js +++ b/plugins/wpgraphql-logging/tests/e2e/utils.js @@ -21,6 +21,16 @@ export async function goToLogsListPage(admin) { await admin.visitAdminPage("/admin.php?page=wpgraphql-logging-view"); } +/** + * Navigate to the WPGraphQL Logging dashboard page + */ +export async function goToDashboardPage(admin, range = "") { + const query = range ? `&range=${range}` : ""; + await admin.visitAdminPage( + `/admin.php?page=wpgraphql-logging-dashboard${query}` + ); +} + /** * Configure logging settings with common options */ diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php new file mode 100644 index 000000000..8d5ef5580 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/Dashboard/TimeSeriesChartTest.php @@ -0,0 +1,108 @@ + $points + */ + private function render( array $points, string $title = 'Entries', string $description = 'Description' ): string { + ob_start(); + ( new TimeSeriesChart() )->render( $points, 'test-chart', $title, $description ); + return (string) ob_get_clean(); + } + + private function point( string $label, int $total, int $errors = 0 ): array { + return [ + 'label' => $label, + 'full_label' => 'Full ' . $label, + 'total' => $total, + 'errors' => $errors, + ]; + } + + public function test_nice_scale(): void { + $this->assertSame( [ 4, 1 ], TimeSeriesChart::nice_scale( 0 ) ); + $this->assertSame( [ 1, 1 ], TimeSeriesChart::nice_scale( 1 ) ); + $this->assertSame( [ 8, 2 ], TimeSeriesChart::nice_scale( 7 ) ); + $this->assertSame( [ 15, 5 ], TimeSeriesChart::nice_scale( 13 ) ); + $this->assertSame( [ 40, 10 ], TimeSeriesChart::nice_scale( 37 ) ); + $this->assertSame( [ 1000, 500 ], TimeSeriesChart::nice_scale( 999 ) ); + } + + public function test_render_outputs_accessible_well_formed_svg(): void { + $svg = $this->render( + [ + $this->point( 'Mon', 10, 2 ), + $this->point( 'Tue', 0 ), + $this->point( 'Wed', 3, 3 ), + ] + ); + + $document = new DOMDocument(); + $this->assertTrue( $document->loadXML( $svg ), 'Chart markup should be well-formed XML.' ); + + $this->assertStringContainsString( 'role="img"', $svg ); + $this->assertStringContainsString( 'aria-labelledby="test-chart-title test-chart-desc"', $svg ); + $this->assertStringContainsString( 'Entries', $svg ); + $this->assertStringContainsString( 'Description', $svg ); + + // One hover group per point, each with a native tooltip. + $this->assertSame( 3, substr_count( $svg, 'class="wpgraphql-logging-chart__slot"' ) ); + $this->assertStringContainsString( 'Full Mon: 10 entries, 2 errors', $svg ); + $this->assertStringContainsString( 'Full Tue: 0 entries, 0 errors', $svg ); + + // Mon has two segments, Tue none, Wed only an error segment. + $this->assertSame( 2, substr_count( $svg, 'wpgraphql-logging-chart__bar--errors' ) ); + $this->assertSame( 1, substr_count( $svg, 'wpgraphql-logging-chart__bar--entries' ) ); + } + + public function test_render_escapes_text(): void { + $svg = $this->render( [ $this->point( 'x', 1 ) ], 'A & B '; + $this->assertSame( DashboardPage::DEFAULT_RANGE, $page->get_current_range() ); + + $_GET['range'] = [ '30d' ]; + $this->assertSame( DashboardPage::DEFAULT_RANGE, $page->get_current_range() ); + } + + public function test_get_ranges_drops_malformed_filtered_ranges(): void { + add_filter( + 'wpgraphql_logging_dashboard_ranges', + static function ( array $ranges ): array { + $ranges['90d'] = [ + 'label' => 'Last 90 days', + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 90, + ]; + $ranges['broken'] = [ 'label' => 'Broken' ]; + $ranges['zero'] = [ + 'label' => 'Zero', + 'bucket_seconds' => 0, + 'bucket_count' => 10, + ]; + $ranges['Bad Slug'] = [ + 'label' => 'Bad', + 'bucket_seconds' => 60, + 'bucket_count' => 10, + ]; + return $ranges; + } + ); + + $ranges = ( new DashboardPage() )->get_ranges(); + + $this->assertSame( [ '24h', '7d', '30d', '90d' ], array_keys( $ranges ) ); + } + + public function test_get_ranges_falls_back_to_defaults_when_filter_returns_garbage(): void { + add_filter( 'wpgraphql_logging_dashboard_ranges', '__return_false' ); + $this->assertSame( [ '24h', '7d', '30d' ], array_keys( ( new DashboardPage() )->get_ranges() ) ); + } + + public function test_get_window_hourly_ends_after_now(): void { + $page = new DashboardPage(); + $now = new DateTimeImmutable( '2026-03-10 14:25:00', new DateTimeZone( 'UTC' ) ); + $window = $page->get_window( + [ + 'label' => '24h', + 'bucket_seconds' => HOUR_IN_SECONDS, + 'bucket_count' => 24, + ], + $now + ); + + $this->assertCount( 24, $window['bucket_starts'] ); + $this->assertSame( '2026-03-09 15:00:00', gmdate( 'Y-m-d H:i:s', $window['start'] ) ); + $this->assertSame( '2026-03-10 15:00:00', gmdate( 'Y-m-d H:i:s', $window['end'] ) ); + $this->assertSame( '2026-03-10 14:00:00', gmdate( 'Y-m-d H:i:s', end( $window['bucket_starts'] ) ) ); + } + + public function test_get_window_daily_uses_local_calendar_days_across_dst(): void { + update_option( 'timezone_string', 'America/New_York' ); + $page = new DashboardPage(); + + // Daylight saving time ends in New York at 02:00 on 2026-11-01, so that day has 25 hours. + $now = new DateTimeImmutable( '2026-11-01 23:30:00', new DateTimeZone( 'America/New_York' ) ); + $window = $page->get_window( + [ + 'label' => '7d', + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 7, + ], + $now + ); + + $local = static fn ( int $timestamp ): string => wp_date( 'Y-m-d H:i', $timestamp ); + + $this->assertSame( '2026-10-26 00:00', $local( $window['start'] ) ); + $this->assertSame( '2026-11-02 00:00', $local( $window['end'] ) ); + $this->assertSame( '2026-11-01 00:00', $local( end( $window['bucket_starts'] ) ) ); + $this->assertSame( 169 * HOUR_IN_SECONDS, $window['end'] - $window['start'] ); + $this->assertGreaterThanOrEqual( end( $window['bucket_starts'] ), $now->getTimestamp() ); + $this->assertLessThan( $window['end'], $now->getTimestamp() ); + } + + public function test_get_window_daily_resets_time_after_nonexistent_midnight(): void { + update_option( 'timezone_string', 'America/Santiago' ); + $page = new DashboardPage(); + + $window = $page->get_window( + [ + 'label' => '7d', + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 7, + ], + new DateTimeImmutable( '2026-09-10 12:00:00', new DateTimeZone( 'America/Santiago' ) ) + ); + + $local_starts = array_map( + static fn ( int $timestamp ): string => wp_date( 'Y-m-d H:i', $timestamp ), + $window['bucket_starts'] + ); + + $this->assertSame( + [ + '2026-09-04 00:00', + '2026-09-05 00:00', + '2026-09-06 01:00', + '2026-09-07 00:00', + '2026-09-08 00:00', + '2026-09-09 00:00', + '2026-09-10 00:00', + ], + $local_starts + ); + } + + public function test_daily_series_respects_half_hour_dst_boundary(): void { + update_option( 'timezone_string', 'Australia/Lord_Howe' ); + $timezone = new DateTimeZone( 'Australia/Lord_Howe' ); + $page = new DashboardPage(); + $window = $page->get_window( + [ + 'label' => '7d', + 'bucket_seconds' => DAY_IN_SECONDS, + 'bucket_count' => 7, + ], + new DateTimeImmutable( '2026-10-07 12:00:00', $timezone ) + ); + $this->insert_row( ( new DateTimeImmutable( '2026-10-05 00:15:00', $timezone ) )->getTimestamp() ); + + $reflection = new ReflectionClass( DashboardPage::class ); + $method = $reflection->getMethod( 'get_series' ); + $method->setAccessible( true ); + /** @var array $series */ + $series = $method->invoke( $page, new WordPressDatabaseLogStats(), $window, DAY_IN_SECONDS ); + + $this->assertSame( 0, $series[3]['total'] ); + $this->assertSame( 1, $series[4]['total'] ); + } + + public function test_fold_series_sums_into_uneven_buckets(): void { + $start = 1000; + $source = [ + 0 => [ 'total' => 1, 'errors' => 0 ], + 1 => [ 'total' => 2, 'errors' => 1 ], + 2 => [ 'total' => 3, 'errors' => 0 ], + 4 => [ 'total' => 4, 'errors' => 2 ], + ]; + + // Buckets of 1, 3 and 1 source units. + $folded = DashboardPage::fold_series( $source, $start, 10, [ 1000, 1010, 1040 ] ); + + $this->assertSame( + [ + [ 'total' => 1, 'errors' => 0 ], + [ 'total' => 5, 'errors' => 1 ], + [ 'total' => 4, 'errors' => 2 ], + ], + $folded + ); + $this->assertSame( [], DashboardPage::fold_series( $source, $start, 10, [] ) ); + } + + public function test_percent_and_level_modifier(): void { + $this->assertSame( 0.0, DashboardPage::percent( 5, 0 ) ); + $this->assertSame( 25.0, DashboardPage::percent( 1, 4 ) ); + $this->assertSame( 'errors', DashboardPage::get_level_modifier( 400 ) ); + $this->assertSame( 'errors', DashboardPage::get_level_modifier( 600 ) ); + $this->assertSame( 'warnings', DashboardPage::get_level_modifier( 300 ) ); + $this->assertSame( 'entries', DashboardPage::get_level_modifier( 250 ) ); + } + + public function test_truncate_keeps_short_text_and_shortens_long_text(): void { + $this->assertSame( 'GetPosts', DashboardPage::truncate( 'GetPosts' ) ); + $this->assertSame( 'abc' . "\u{2026}", DashboardPage::truncate( 'abcdef', 3 ) ); + $this->assertSame( 'ééé' . "\u{2026}", DashboardPage::truncate( 'éééé', 3 ) ); + } + + public function test_get_stats_service_defaults_to_database_and_is_filterable(): void { + $page = new DashboardPage(); + $this->assertInstanceOf( WordPressDatabaseLogStats::class, $page->get_stats_service() ); + + add_filter( 'wpgraphql_logging_dashboard_stats_service', '__return_null' ); + $this->assertNull( $page->get_stats_service() ); + $this->assertNull( $page->get_dashboard_data( '7d' ) ); + } + + public function test_get_dashboard_data_returns_null_for_unknown_range(): void { + $this->assertNull( ( new DashboardPage() )->get_dashboard_data( 'nope' ) ); + } + + public function test_get_dashboard_data_aggregates_and_caches(): void { + $now = time(); + $this->insert_row( $now - 60 ); + $this->insert_row( $now - 120, 400, 'ERROR', 'GetPage' ); + $this->insert_row( $now - ( 40 * DAY_IN_SECONDS ) ); // Outside every range. + + $page = new DashboardPage(); + $data = $page->get_dashboard_data( '7d' ); + + $this->assertIsArray( $data ); + $this->assertSame( [ 'total' => 2, 'errors' => 1, 'operations' => 2 ], $data['summary'] ); + $this->assertCount( 7, $data['series'] ); + $this->assertCount( 7, $data['bucket_starts'] ); + $this->assertSame( 2, array_sum( array_column( $data['series'], 'total' ) ) ); + $this->assertNotFalse( get_transient( DashboardPage::CACHE_KEY_PREFIX . '7d' ) ); + + // A new row is not visible until the cache expires. + $this->insert_row( $now - 30 ); + $this->assertSame( 2, $page->get_dashboard_data( '7d' )['summary']['total'] ); + + delete_transient( DashboardPage::CACHE_KEY_PREFIX . '7d' ); + $this->assertSame( 3, $page->get_dashboard_data( '7d' )['summary']['total'] ); + } + + public function test_cache_can_be_disabled(): void { + add_filter( 'wpgraphql_logging_dashboard_cache_ttl', '__return_zero' ); + ( new DashboardPage() )->get_dashboard_data( '24h' ); + $this->assertFalse( get_transient( DashboardPage::CACHE_KEY_PREFIX . '24h' ) ); + } + + public function test_nonpositive_cache_ttl_bypasses_existing_cache(): void { + $cached = [ + 'range' => '24h', + 'summary' => [ 'total' => 99 ], + ]; + set_transient( DashboardPage::CACHE_KEY_PREFIX . '24h', $cached, MINUTE_IN_SECONDS ); + $this->insert_row( time() - 60 ); + add_filter( 'wpgraphql_logging_dashboard_cache_ttl', '__return_zero' ); + + $data = ( new DashboardPage() )->get_dashboard_data( '24h' ); + + $this->assertSame( 1, $data['summary']['total'] ); + $this->assertSame( $cached, get_transient( DashboardPage::CACHE_KEY_PREFIX . '24h' ) ); + } + + public function test_custom_stats_service_is_used(): void { + $stub = new class() implements LogStatsInterface { + public function get_summary( DateTimeInterface $start, DateTimeInterface $end ): array { + return [ 'total' => 42, 'errors' => 0, 'operations' => 1 ]; + } + + public function get_time_series( DateTimeInterface $start, int $bucket_seconds, int $bucket_count ): array { + return array_fill( 0, $bucket_count, [ 'total' => 0, 'errors' => 0 ] ); + } + + public function get_level_breakdown( DateTimeInterface $start, DateTimeInterface $end ): array { + return []; + } + + public function get_top_operations( DateTimeInterface $start, DateTimeInterface $end, int $limit = 10 ): array { + return []; + } + }; + add_filter( 'wpgraphql_logging_dashboard_stats_service', static fn () => $stub ); + + $data = ( new DashboardPage() )->get_dashboard_data( '30d' ); + $this->assertSame( 42, $data['summary']['total'] ); + $this->assertCount( 30, $data['series'] ); + } + + public function test_label_series_formats_hourly_and_daily_labels(): void { + update_option( 'date_format', 'Y-m-d' ); + update_option( 'time_format', 'H:i' ); + $page = new DashboardPage(); + $start = gmmktime( 0, 0, 0, 1, 2, 2026 ); + + $hourly = $page->label_series( [ [ 'total' => 3, 'errors' => 1 ] ], [ $start ], HOUR_IN_SECONDS ); + $this->assertSame( '00:00', $hourly[0]['label'] ); + $this->assertSame( '2026-01-02 00:00', $hourly[0]['full_label'] ); + $this->assertSame( 3, $hourly[0]['total'] ); + + // Missing counts are treated as zero. + $daily = $page->label_series( [], [ $start ], DAY_IN_SECONDS ); + $this->assertSame( 'Jan 2', $daily[0]['label'] ); + $this->assertSame( '2026-01-02', $daily[0]['full_label'] ); + $this->assertSame( 0, $daily[0]['total'] ); + } + + public function test_render_page_outputs_dashboard_with_escaped_operation_names(): void { + $this->set_as_admin(); + $this->insert_row( time() - 60, 200, 'INFO', '' ); + + ob_start(); + ( new DashboardPage() )->render_page(); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'WPGraphQL Logging Dashboard', $html ); + $this->assertStringContainsString( 'role="img"', $html ); + $this->assertStringContainsString( 'Show data table', $html ); + $this->assertStringContainsString( 'aria-current="page"', $html ); + $this->assertStringNotContainsString( 'assertStringContainsString( '<img src=x onerror=alert(1)>', $html ); + } + + public function test_render_page_shows_notice_when_dashboard_unavailable(): void { + $this->set_as_admin(); + add_filter( 'wpgraphql_logging_dashboard_stats_service', '__return_null' ); + + ob_start(); + ( new DashboardPage() )->render_page(); + $html = (string) ob_get_clean(); + + $this->assertStringContainsString( 'notice-warning', $html ); + $this->assertStringNotContainsString( 'role="img"', $html ); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php new file mode 100644 index 000000000..081cc8057 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Database/WordPressDatabaseLogStatsTest.php @@ -0,0 +1,194 @@ +log_service = new WordPressDatabaseLogService(); + $this->log_service->activate(); + $this->log_service->delete_all_entities(); + $this->stats = new WordPressDatabaseLogStats(); + $this->start = new DateTimeImmutable( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) ); + } + + protected function tearDown(): void { + $this->log_service->delete_all_entities(); + parent::tearDown(); + } + + /** + * Inserts a log row with an explicit datetime. + * + * @param string $datetime UTC datetime. + * @param int $level Monolog level. + * @param string $level_name Level name. + * @param string|null $operation Operation name stored in context.operation_name. + */ + private function insert_row( string $datetime, int $level = 200, string $level_name = 'INFO', ?string $operation = 'GetPosts' ): void { + global $wpdb; + $wpdb->insert( + WordPressDatabaseEntity::get_table_name(), + [ + 'channel' => 'wpgraphql_logging', + 'level' => $level, + 'level_name' => $level_name, + 'message' => 'WPGraphQL Response', + 'context' => wp_json_encode( [ 'operation_name' => $operation ] ), + 'extra' => wp_json_encode( [] ), + 'datetime' => $datetime, + ] + ); + } + + private function end( int $hours ): DateTimeImmutable { + return $this->start->modify( sprintf( '+%d hours', $hours ) ); + } + + public function test_summary_is_zero_for_empty_table(): void { + $summary = $this->stats->get_summary( $this->start, $this->end( 24 ) ); + $this->assertSame( [ 'total' => 0, 'errors' => 0, 'operations' => 0 ], $summary ); + } + + public function test_summary_counts_entries_errors_and_distinct_operations(): void { + $this->insert_row( '2026-01-01 01:00:00' ); + $this->insert_row( '2026-01-01 02:00:00', 400, 'ERROR' ); + $this->insert_row( '2026-01-01 03:00:00', 500, 'CRITICAL', 'GetPage' ); + $this->insert_row( '2026-01-01 04:00:00', 300, 'WARNING', null ); + // Outside the window. + $this->insert_row( '2025-12-31 23:59:59', 400, 'ERROR', 'Old' ); + $this->insert_row( '2026-01-02 00:00:00', 400, 'ERROR', 'Later' ); + + $summary = $this->stats->get_summary( $this->start, $this->end( 24 ) ); + + $this->assertSame( 4, $summary['total'] ); + $this->assertSame( 2, $summary['errors'], 'ERROR and above count as errors; WARNING does not.' ); + $this->assertSame( 2, $summary['operations'], 'Unnamed operations are not counted as distinct operations.' ); + } + + public function test_time_series_fills_empty_buckets_and_groups_by_bucket(): void { + $this->insert_row( '2026-01-01 00:00:00' ); + $this->insert_row( '2026-01-01 00:59:59', 400, 'ERROR' ); + $this->insert_row( '2026-01-01 05:30:00' ); + $this->insert_row( '2026-01-01 23:59:59', 500, 'CRITICAL' ); + // Outside the window. + $this->insert_row( '2026-01-02 00:00:00' ); + + $series = $this->stats->get_time_series( $this->start, HOUR_IN_SECONDS, 24 ); + + $this->assertCount( 24, $series ); + $this->assertSame( [ 'total' => 2, 'errors' => 1 ], $series[0] ); + $this->assertSame( [ 'total' => 0, 'errors' => 0 ], $series[1] ); + $this->assertSame( [ 'total' => 1, 'errors' => 0 ], $series[5] ); + $this->assertSame( [ 'total' => 1, 'errors' => 1 ], $series[23] ); + $this->assertSame( 4, array_sum( array_column( $series, 'total' ) ) ); + } + + public function test_time_series_daily_buckets(): void { + $this->insert_row( '2026-01-01 12:00:00' ); + $this->insert_row( '2026-01-03 00:00:00' ); + $this->insert_row( '2026-01-03 23:00:00', 400, 'ERROR' ); + + $series = $this->stats->get_time_series( $this->start, DAY_IN_SECONDS, 7 ); + + $this->assertCount( 7, $series ); + $this->assertSame( 1, $series[0]['total'] ); + $this->assertSame( 0, $series[1]['total'] ); + $this->assertSame( [ 'total' => 2, 'errors' => 1 ], $series[2] ); + } + + public function test_level_breakdown_orders_by_level_descending(): void { + $this->insert_row( '2026-01-01 01:00:00' ); + $this->insert_row( '2026-01-01 02:00:00' ); + $this->insert_row( '2026-01-01 03:00:00', 400, 'ERROR' ); + + $levels = $this->stats->get_level_breakdown( $this->start, $this->end( 24 ) ); + + $this->assertSame( + [ + [ 'level' => 400, 'level_name' => 'ERROR', 'total' => 1 ], + [ 'level' => 200, 'level_name' => 'INFO', 'total' => 2 ], + ], + $levels + ); + } + + public function test_top_operations_groups_unnamed_and_respects_limit(): void { + $this->insert_row( '2026-01-01 01:00:00', 200, 'INFO', 'GetPosts' ); + $this->insert_row( '2026-01-01 02:00:00', 400, 'ERROR', 'GetPosts' ); + $this->insert_row( '2026-01-01 03:00:00', 200, 'INFO', 'GetPosts' ); + $this->insert_row( '2026-01-01 04:00:00', 200, 'INFO', null ); + $this->insert_row( '2026-01-01 05:00:00', 200, 'INFO', null ); + $this->insert_row( '2026-01-01 06:00:00', 200, 'INFO', 'GetMenu' ); + + $operations = $this->stats->get_top_operations( $this->start, $this->end( 24 ), 2 ); + + $this->assertSame( + [ + [ 'operation' => 'GetPosts', 'total' => 3, 'errors' => 1 ], + [ 'operation' => '', 'total' => 2, 'errors' => 0 ], + ], + $operations + ); + } + + public function test_top_operations_treats_missing_operation_key_as_unnamed(): void { + global $wpdb; + $wpdb->insert( + WordPressDatabaseEntity::get_table_name(), + [ + 'channel' => 'wpgraphql_logging', + 'level' => 200, + 'level_name' => 'INFO', + 'message' => 'WPGraphQL Pre Request', + 'context' => wp_json_encode( [ 'query' => '{ posts { nodes { id } } }' ] ), + 'extra' => wp_json_encode( [] ), + 'datetime' => '2026-01-01 01:00:00', + ] + ); + + $operations = $this->stats->get_top_operations( $this->start, $this->end( 24 ) ); + + $this->assertSame( [ [ 'operation' => '', 'total' => 1, 'errors' => 0 ] ], $operations ); + } + + public function test_operation_name_null_is_distinct_from_an_unnamed_operation(): void { + $this->insert_row( '2026-01-01 01:00:00', 200, 'INFO', 'null' ); + $this->insert_row( '2026-01-01 02:00:00', 200, 'INFO', null ); + + $this->assertSame( 1, $this->stats->get_summary( $this->start, $this->end( 24 ) )['operations'] ); + $operations = $this->stats->get_top_operations( $this->start, $this->end( 24 ) ); + usort( $operations, static fn ( array $left, array $right ): int => strcmp( $left['operation'], $right['operation'] ) ); + + $this->assertSame( + [ + [ 'operation' => '', 'total' => 1, 'errors' => 0 ], + [ 'operation' => 'null', 'total' => 1, 'errors' => 0 ], + ], + $operations + ); + } +}