Skip to content
Merged
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
139 changes: 138 additions & 1 deletion includes/Modules/Analytics_4/Email_Reporting/Report_Data_Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,14 @@ public function __construct( ?Email_Report_Payload_Processor $report_processor =
* @return array Section payloads.
*/
public function build_sections_from_module_payload( $module_payload ) {
$sections = array();
$sections = array();
$top_channels_by_event = $this->extract_top_channels_by_event( $module_payload );

foreach ( $module_payload as $section_key => $section_data ) {
if ( $this->is_conversion_event_top_channel_key( $section_key ) ) {
continue;
}

list( $reports ) = $this->normalize_section_input( $section_data );

foreach ( $reports as $report ) {
Expand All @@ -109,6 +114,8 @@ public function build_sections_from_module_payload( $module_payload ) {
$payload['title'] = '';
}

$payload = $this->maybe_merge_conversion_event_top_channel( $payload, $section_key, $top_channels_by_event );

$sections[] = $payload;
}
}
Expand Down Expand Up @@ -265,4 +272,134 @@ function ( $dimension_value ) {

return array( $formatted_values, $labels );
}

/**
* Extracts top traffic channels from conversion sidecar payloads.
*
* @since n.e.x.t
*
* @param array $module_payload Analytics module payload.
* @return array Event slug => top channel.
*/
private function extract_top_channels_by_event( $module_payload ) {
$top_channels_by_event = array();

if ( ! is_array( $module_payload ) ) {
return $top_channels_by_event;
}

foreach ( $module_payload as $section_key => $section_data ) {
if ( ! $this->is_conversion_event_top_channel_key( $section_key ) ) {
continue;
}

$event_slug = $this->get_conversion_event_slug_from_top_channel_key( $section_key );
if ( '' === $event_slug ) {
continue;
}

$top_channel = $this->extract_top_channel_from_section_data( $section_data );
if ( '' === $top_channel ) {
continue;
}

$top_channels_by_event[ $event_slug ] = $top_channel;
}

return $top_channels_by_event;
}

/**
* Merges sidecar top-channel data into a conversion-event payload.
*
* @since n.e.x.t
*
* @param array $payload Section payload.
* @param string $section_key Section key.
* @param array $top_channels_by_event Event slug => top channel map.
* @return array Section payload.
*/
private function maybe_merge_conversion_event_top_channel( $payload, $section_key, $top_channels_by_event ) {
if ( 0 !== strpos( $section_key, 'conversion_event_' ) ) {
return $payload;
}

$event_slug = substr( $section_key, strlen( 'conversion_event_' ) );
if ( '' === $event_slug ) {
return $payload;
}

if ( empty( $top_channels_by_event[ $event_slug ] ) ) {
$payload['dimensions'] = array();
$payload['dimension_values'] = array();

return $payload;
}

$payload['dimensions'] = array( 'sessionDefaultChannelGroup' );
$payload['dimension_values'] = array( $top_channels_by_event[ $event_slug ] );

return $payload;
}

/**
* Extracts the top channel value from sidecar report data.
*
* @since n.e.x.t
*
* @param mixed $section_data Section data for a top-channel request.
* @return string Top channel value, or empty string when unavailable.
*/
private function extract_top_channel_from_section_data( $section_data ) {
list( $reports ) = $this->normalize_section_input( $section_data );
if ( empty( $reports ) ) {
return '';
}

foreach ( $reports as $report ) {
$processed_report = $this->report_processor->process_single_report( $report );
$rows = $processed_report['rows'] ?? array();

if ( empty( $rows ) || ! is_array( $rows ) ) {
continue;
}

foreach ( $rows as $row ) {
$top_channel = $row['dimensions']['sessionDefaultChannelGroup'] ?? '';
if ( '' !== $top_channel ) {
return $top_channel;
}
}
}

return '';
}

/**
* Determines whether section key is a conversion top-channel sidecar key.
*
* @since n.e.x.t
*
* @param string $section_key Section key.
* @return bool True when key is a conversion top-channel key.
*/
private function is_conversion_event_top_channel_key( $section_key ) {
return 0 === strpos( $section_key, 'conversion_event_top_channel_' );
}

/**
* Gets conversion event slug from a top-channel sidecar key.
*
* @since n.e.x.t
*
* @param string $section_key Section key.
* @return string Conversion event slug.
*/
private function get_conversion_event_slug_from_top_channel_key( $section_key ) {
if ( ! $this->is_conversion_event_top_channel_key( $section_key ) ) {
return '';
}

return substr( $section_key, strlen( 'conversion_event_top_channel_' ) );
}
}
24 changes: 24 additions & 0 deletions includes/Modules/Analytics_4/Email_Reporting/Report_Options.php
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,30 @@ public function get_total_conversion_events_options() {
* @return array Report request options array.
*/
public function get_conversion_event_options( string $event_name ) {
return $this->with_current_range(
array(
'metrics' => array(
array( 'name' => 'eventCount' ),
),
'dimensionFilters' => array(
'eventName' => array(
'value' => $event_name,
),
),
'keepEmptyRows' => true,
)
);
}

/**
* Gets report options for top conversion channel by event.
*
* @since n.e.x.t
*
* @param string $event_name Conversion event name.
* @return array Report request options array.
*/
public function get_conversion_event_top_channel_options( string $event_name ) {
return $this->with_current_range(
array(
'metrics' => array(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function build_requests( array $custom_titles = array() ) {
}

$requests[ $request_key ] = $this->report_options->get_conversion_event_options( $event_name );
$requests[ sprintf( 'conversion_event_top_channel_%s', $event_name_slug ) ] = $this->report_options->get_conversion_event_top_channel_options( $event_name );
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
use Google\Site_Kit\Core\Email_Reporting\Email_Log;
use Google\Site_Kit\Core\Email_Reporting\Email_Report_Section_Builder;
use Google\Site_Kit\Core\Email_Reporting\Email_Report_Data_Section_Part;
use Google\Site_Kit\Core\Email_Reporting\Sections_Map;
use Google\Site_Kit\Core\Golinks\Dashboard_Golink_Handler;
use Google\Site_Kit\Core\Golinks\Golinks;
use Google\Site_Kit\Tests\TestCase;

class Email_Report_Section_BuilderTest extends TestCase {
Expand Down Expand Up @@ -139,4 +142,180 @@ public function test_build_sections__returns_wp_error_for_search_console_error_p
$this->assertWPError( $sections, 'Search Console errors should be propagated as WP_Error.' );
$this->assertSame( 'email_report_search_console_missing_result', $sections->get_error_code(), 'Expected original Search Console error code to be preserved.' );
}

public function test_build_sections__uses_total_conversion_count_and_top_channel_sidecar_data() {
$context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE );
$builder = new Email_Report_Section_Builder( $context );

$payloads = array(
array(
'total_conversion_events' => $this->get_conversion_event_total_report( '90' ),
'conversion_event_add_to_cart' => $this->get_conversion_event_total_report( '50' ),
'conversion_event_begin_checkout' => $this->get_conversion_event_total_report( '30' ),
'conversion_event_purchase' => $this->get_conversion_event_total_report( '10' ),
'conversion_event_top_channel_add_to_cart' => $this->get_conversion_event_top_channel_report( 'Organic Search', '5' ),
'conversion_event_top_channel_begin_checkout' => $this->get_conversion_event_top_channel_report( 'Direct', '7' ),
'conversion_event_top_channel_purchase' => $this->get_conversion_event_top_channel_report( 'Paid Search', '999' ),
),
);

$sections = $builder->build_sections( 'analytics-4', $payloads, 'en_US' );

$this->assertIsArray( $sections, 'Expected analytics sections array.' );
$this->assertCount( 4, $sections, 'Expected total plus three conversion event sections; top-channel sidecar reports should not render as standalone sections.' );

$section_by_key = $this->map_sections_by_key( $sections );

$this->assertArrayNotHasKey( 'conversion_event_top_channel_add_to_cart', $section_by_key, 'Top-channel sidecar payload should not become a standalone section.' );
$this->assertArrayHasKey( 'conversion_event_add_to_cart', $section_by_key, 'Expected conversion event section to be present.' );

$add_to_cart_section = $section_by_key['conversion_event_add_to_cart'];
$this->assertSame( array( '50' ), $add_to_cart_section->get_values(), 'Conversion event value should come from total event count.' );
$this->assertSame( array( 'sessionDefaultChannelGroup' ), $add_to_cart_section->get_dimensions(), 'Conversion event should expose top-channel dimension only when sidecar data exists.' );
$this->assertSame( array( 'Organic Search' ), $add_to_cart_section->get_dimension_values(), 'Conversion event top channel should be merged from sidecar payload.' );

$sections_payload = $this->to_sections_payload( $sections );
$golinks = new Golinks( $context );
$golinks->register_handler( 'dashboard', new Dashboard_Golink_Handler() );
$sections_map = new Sections_Map( $context, $sections_payload, $golinks );
$section_map = $sections_map->get_sections();

$this->assertArrayHasKey( 'is_my_site_helping_my_business_grow', $section_map, 'Expected conversions section in section map.' );
$this->assertSame(
array(
'total_conversion_events',
'conversion_event_add_to_cart',
'conversion_event_begin_checkout',
),
array_keys( $section_map['is_my_site_helping_my_business_grow']['section_parts'] ),
'Conversion rows should be ranked by total event counts, not top-channel sidecar counts.'
);
}

public function test_build_sections__keeps_conversion_event_row_when_top_channel_sidecar_is_missing() {
$context = new Context( GOOGLESITEKIT_PLUGIN_MAIN_FILE );
$builder = new Email_Report_Section_Builder( $context );

$payloads = array(
array(
'total_conversion_events' => $this->get_conversion_event_total_report( '12' ),
'conversion_event_add_to_cart' => $this->get_conversion_event_total_report( '4' ),
),
);

$sections = $builder->build_sections( 'analytics-4', $payloads, 'en_US' );

$this->assertIsArray( $sections, 'Expected analytics sections array.' );
$this->assertCount( 2, $sections, 'Expected total plus conversion event section.' );

$section_by_key = $this->map_sections_by_key( $sections );

$this->assertArrayHasKey( 'conversion_event_add_to_cart', $section_by_key, 'Conversion event should be present even when top-channel sidecar data is missing.' );

$add_to_cart_section = $section_by_key['conversion_event_add_to_cart'];
$this->assertSame( array( '4' ), $add_to_cart_section->get_values(), 'Conversion event value should still come from total event count.' );
$this->assertSame( array(), $add_to_cart_section->get_dimensions(), 'Conversion event should not expose a top-channel dimension when sidecar data is missing.' );
$this->assertSame( array(), $add_to_cart_section->get_dimension_values(), 'Conversion event should not expose a top-channel value when sidecar data is missing.' );
}

/**
* Converts built section parts into section payload used by sections map.
*
* @param Email_Report_Data_Section_Part[] $sections Built section parts.
* @return array
*/
private function to_sections_payload( array $sections ) {
$payload = array();

foreach ( $sections as $section ) {
$dimensions = $section->get_dimensions();
$dimension_values = $section->get_dimension_values();
$first_dimension = $dimensions[0] ?? '';
$first_value = $dimension_values[0] ?? '';
$first_label = is_array( $first_value ) ? ( $first_value['label'] ?? '' ) : $first_value;

$payload[ $section->get_section_key() ] = array(
'value' => $section->get_values()[0] ?? '',
'label' => $section->get_labels()[0] ?? '',
'event_name' => $section->get_event_names()[0] ?? '',
'dimension' => $first_dimension,
'dimension_value' => $first_label,
'change_context' => 'Compared to previous 7 days',
);
}

return $payload;
}

/**
* Builds a minimal GA4 conversion event totals report fixture.
*
* @param string $value Event count value.
* @return array
*/
private function get_conversion_event_total_report( $value ) {
return array(
'metricHeaders' => array(
array(
'name' => 'eventCount',
'type' => 'TYPE_INTEGER',
),
),
'totals' => array(
array(
'metricValues' => array(
array( 'value' => (string) $value ),
),
),
),
'rowCount' => 1,
);
}

/**
* Builds a minimal GA4 conversion event top-channel report fixture.
*
* @param string $channel Top channel name.
* @param string $value Event count value.
* @return array
*/
private function get_conversion_event_top_channel_report( $channel, $value ) {
return array(
'dimensionHeaders' => array(
array( 'name' => 'sessionDefaultChannelGroup' ),
),
'metricHeaders' => array(
array(
'name' => 'eventCount',
'type' => 'TYPE_INTEGER',
),
),
'rows' => array(
array(
'dimensionValues' => array(
array( 'value' => (string) $channel ),
),
'metricValues' => array(
array( 'value' => (string) $value ),
),
),
),
);
}

/**
* Maps section parts by section key for easier assertions.
*
* @param Email_Report_Data_Section_Part[] $sections Built section parts.
* @return Email_Report_Data_Section_Part[]
*/
private function map_sections_by_key( array $sections ) {
$section_by_key = array();

foreach ( $sections as $section ) {
$section_by_key[ $section->get_section_key() ] = $section;
}

return $section_by_key;
}
}
Loading
Loading