diff --git a/plugins/performance-lab/includes/server-timing/class-perflab-server-timing-metric.php b/plugins/performance-lab/includes/server-timing/class-perflab-server-timing-metric.php index fc01e682c3..4d557a4eae 100644 --- a/plugins/performance-lab/includes/server-timing/class-perflab-server-timing-metric.php +++ b/plugins/performance-lab/includes/server-timing/class-perflab-server-timing-metric.php @@ -37,6 +37,14 @@ class Perflab_Server_Timing_Metric { */ private $before_value; + /** + * The metric description. + * + * @since n.e.x.t + * @var string|null + */ + private $description = null; + /** * Constructor. * @@ -142,4 +150,36 @@ public function measure_after(): void { $this->set_value( ( microtime( true ) - $this->before_value ) * 1000.0 ); } + + /** + * Sets the metric description. + * + * @since n.e.x.t + * + * @param non-empty-string $description The metric description. + */ + public function set_description( string $description ): void { + if ( 0 !== did_action( 'perflab_server_timing_send_header' ) && ! doing_action( 'perflab_server_timing_send_header' ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: WordPress action name */ + sprintf( esc_html__( 'The method must be called before or during the %s action.', 'performance-lab' ), 'perflab_server_timing_send_header' ), + '' + ); + return; + } + + $this->description = $description; + } + + /** + * Gets the metric description. + * + * @since n.e.x.t + * + * @return string|null The metric description, or null if none set. + */ + public function get_description(): ?string { + return $this->description; + } } diff --git a/plugins/performance-lab/includes/server-timing/class-perflab-server-timing.php b/plugins/performance-lab/includes/server-timing/class-perflab-server-timing.php index ba16cf6218..6e69553a92 100644 --- a/plugins/performance-lab/includes/server-timing/class-perflab-server-timing.php +++ b/plugins/performance-lab/includes/server-timing/class-perflab-server-timing.php @@ -290,23 +290,32 @@ function ( string $output, ?int $phase ): string { * @since 1.8.0 * * @param Perflab_Server_Timing_Metric $metric The metric to format. - * @return string|null Segment for the Server-Timing header, or null if no value set. + * @return string Segment for the Server-Timing header. */ - private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ): ?string { - $value = $metric->get_value(); + private function format_metric_header_value( Perflab_Server_Timing_Metric $metric ): string { + $value = $metric->get_value(); + $description = $metric->get_description(); - // If no value is set, make sure it's just passed through. - if ( null === $value ) { - return null; - } + // See https://github.com/WordPress/performance/issues/955. + $name = preg_replace( '/[^!#$%&\'*+\-.^_`|~0-9a-zA-Z]/', '-', $metric->get_slug() ); + + $parts = array( sprintf( 'wp-%s', $name ) ); - if ( is_float( $value ) ) { - $value = round( $value, 2 ); + if ( null !== $value ) { + if ( is_float( $value ) ) { + $value = round( $value, 2 ); + } + $parts[] = sprintf( 'dur=%s', $value ); } - // See https://github.com/WordPress/performance/issues/955. - $name = preg_replace( '/[^!#$%&\'*+\-.^_`|~0-9a-zA-Z]/', '-', $metric->get_slug() ); + if ( null !== $description ) { + // Sanitize description for HTTP header quoted-string format. + // Remove control characters (CR/LF) and escape backslashes and quotes. + $sanitized_description = str_replace( array( "\r", "\n" ), '', $description ); + $sanitized_description = addcslashes( $sanitized_description, '\\"' ); + $parts[] = sprintf( 'desc="%s"', $sanitized_description ); + } - return sprintf( 'wp-%1$s;dur=%2$s', $name, $value ); + return implode( ';', $parts ); } } diff --git a/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing-metric.php b/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing-metric.php index 842272f1c5..db35bad213 100644 --- a/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing-metric.php +++ b/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing-metric.php @@ -75,4 +75,20 @@ public function test_measure_after_without_before(): void { $this->assertNull( $this->metric->get_value() ); } + + /** + * @covers Perflab_Server_Timing_Metric::set_description + * @covers Perflab_Server_Timing_Metric::get_description + */ + public function test_set_description_with_string(): void { + $this->metric->set_description( 'Database queries' ); + $this->assertSame( 'Database queries', $this->metric->get_description() ); + } + + /** + * @covers Perflab_Server_Timing_Metric::get_description + */ + public function test_get_description_returns_null_by_default(): void { + $this->assertNull( $this->metric->get_description() ); + } } diff --git a/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing.php b/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing.php index 561c3bb481..799d2c9250 100644 --- a/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing.php +++ b/plugins/performance-lab/tests/includes/server-timing/test-perflab-server-timing.php @@ -7,6 +7,7 @@ /** * @group server-timing + * @phpstan-import-type MetricArguments from Perflab_Server_Timing */ class Test_Perflab_Server_Timing extends WP_UnitTestCase { @@ -154,7 +155,8 @@ public function test_register_metric_replaces_slashes(): void { /** * @dataProvider data_get_header * - * @phpstan-param array $metrics + * @param string $expected The expected header value. + * @param array $metrics The metric configurations. */ public function test_get_header( string $expected, array $metrics ): void { foreach ( $metrics as $metric_slug => $args ) { @@ -281,4 +283,161 @@ public function test_use_output_buffer( callable $set_up, bool $expected ): void $set_up(); $this->assertSame( $expected, $this->server_timing->use_output_buffer() ); } + + /** + * @dataProvider data_get_header_with_description + * + * @param string $expected The expected header value. + * @param array $metrics The metric configurations. + */ + public function test_get_header_with_description( string $expected, array $metrics ): void { + foreach ( $metrics as $metric_slug => $args ) { + $this->server_timing->register_metric( $metric_slug, $args ); + } + $this->assertSame( $expected, $this->server_timing->get_header() ); + } + + /** + * @return array + */ + public function data_get_header_with_description(): array { + $measure_with_description = static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_value( 100 ); + $metric->set_description( 'Database queries' ); + }; + $measure_description_only = static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_description( 'Cache operations' ); + }; + $measure_duration_only = static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_value( 50 ); + }; + $measure_name_only = static function ( Perflab_Server_Timing_Metric $metric ): void { + unset( $metric ); + }; + + return array( + 'metric with duration and description' => array( + 'wp-db-query;dur=100;desc="Database queries"', + array( + 'db-query' => array( + 'measure_callback' => $measure_with_description, + 'access_cap' => 'exist', + ), + ), + ), + 'metric with description only' => array( + 'wp-cache-ops;desc="Cache operations"', + array( + 'cache-ops' => array( + 'measure_callback' => $measure_description_only, + 'access_cap' => 'exist', + ), + ), + ), + 'metric with duration only' => array( + 'wp-duration-only;dur=50', + array( + 'duration-only' => array( + 'measure_callback' => $measure_duration_only, + 'access_cap' => 'exist', + ), + ), + ), + 'metric with name only' => array( + 'wp-missed-cache', + array( + 'missed-cache' => array( + 'measure_callback' => $measure_name_only, + 'access_cap' => 'exist', + ), + ), + ), + 'mixed metrics' => array( + 'wp-with-both;dur=100;desc="Database queries", wp-desc-only;desc="Cache operations", wp-dur-only;dur=50, wp-name-only', + array( + 'with-both' => array( + 'measure_callback' => $measure_with_description, + 'access_cap' => 'exist', + ), + 'desc-only' => array( + 'measure_callback' => $measure_description_only, + 'access_cap' => 'exist', + ), + 'dur-only' => array( + 'measure_callback' => $measure_duration_only, + 'access_cap' => 'exist', + ), + 'name-only' => array( + 'measure_callback' => $measure_name_only, + 'access_cap' => 'exist', + ), + ), + ), + ); + } + + /** + * @dataProvider data_get_header_with_description_edge_cases + * + * @param string $expected The expected header value. + * @param array $metrics The metric configurations. + */ + public function test_get_header_with_description_edge_cases( string $expected, array $metrics ): void { + foreach ( $metrics as $metric_slug => $args ) { + $this->server_timing->register_metric( $metric_slug, $args ); + } + $this->assertSame( $expected, $this->server_timing->get_header() ); + } + + /** + * @return array + */ + public function data_get_header_with_description_edge_cases(): array { + return array( + 'description with double quote' => array( + 'wp-quoted;desc="Say \\"hello\\""', + array( + 'quoted' => array( + 'measure_callback' => static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_description( 'Say "hello"' ); + }, + 'access_cap' => 'exist', + ), + ), + ), + 'description with backslash' => array( + 'wp-backslash;desc="path\\\\to\\\\file"', + array( + 'backslash' => array( + 'measure_callback' => static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_description( 'path\\to\\file' ); + }, + 'access_cap' => 'exist', + ), + ), + ), + 'description with newline' => array( + 'wp-newline;desc="Line 1Line 2"', + array( + 'newline' => array( + 'measure_callback' => static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_description( "Line 1\nLine 2" ); + }, + 'access_cap' => 'exist', + ), + ), + ), + 'description with carriage return' => array( + 'wp-cr;desc="BeforeAfter"', + array( + 'cr' => array( + 'measure_callback' => static function ( Perflab_Server_Timing_Metric $metric ): void { + $metric->set_description( "Before\rAfter" ); + }, + 'access_cap' => 'exist', + ), + ), + ), + ); + } }