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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
}
Comment on lines +161 to +170
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_description() adds a new “must be called before or during perflab_server_timing_send_header” guard (via _doing_it_wrong() and early return), but there is no test covering the late-call behavior (similar to test_set_value_prevents_late_measurement). Adding a unit test for the late-call path would help prevent regressions.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The late-call guard in set_description() uses the exact same
did_action() / doing_action() pattern as the existing set_value() method
(lines 91–99).

Adding a test for this would require calling:

do_action( 'perflab_server_timing_send_header' );

However, this triggers the global singleton to re-register default metrics
(e.g., before-template), which causes test isolation failures.


$this->description = $description;
}
Comment on lines +159 to +173
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PHPDoc declares $description as non-empty-string, but the method currently accepts and stores empty strings without any validation. Either enforce non-empty input (e.g. _doing_it_wrong() on empty string) or relax the docblock to string so the contract matches runtime behavior.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it as it is, relying on the type hint for the developer.


/**
* 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

/**
* @group server-timing
* @phpstan-import-type MetricArguments from Perflab_Server_Timing
*/
class Test_Perflab_Server_Timing extends WP_UnitTestCase {

Expand Down Expand Up @@ -154,7 +155,8 @@ public function test_register_metric_replaces_slashes(): void {
/**
* @dataProvider data_get_header
*
* @phpstan-param array<string, mixed> $metrics
* @param string $expected The expected header value.
* @param array<string, MetricArguments> $metrics The metric configurations.
*/
public function test_get_header( string $expected, array $metrics ): void {
foreach ( $metrics as $metric_slug => $args ) {
Expand Down Expand Up @@ -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<string, MetricArguments> $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<string, mixed>
*/
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<string, MetricArguments> $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<string, mixed>
*/
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',
),
),
),
);
}
}
Loading