Skip to content

Commit e3373cf

Browse files
adekbadekclaude
andauthored
feat(analytics): auto-provision GA4 custom dimensions (#4657)
* feat(analytics): auto-provision GA4 custom dimensions Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ec551c1 commit e3373cf

8 files changed

Lines changed: 1323 additions & 0 deletions

includes/class-newspack.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ private function includes() {
218218
include_once NEWSPACK_ABSPATH . 'includes/plugins/class-gravityforms.php';
219219
include_once NEWSPACK_ABSPATH . 'includes/plugins/google-site-kit/class-googlesitekit.php';
220220
include_once NEWSPACK_ABSPATH . 'includes/plugins/google-site-kit/class-googlesitekit-logger.php';
221+
include_once NEWSPACK_ABSPATH . 'includes/plugins/google-site-kit/class-ga4-custom-dimensions.php';
221222
include_once NEWSPACK_ABSPATH . 'includes/plugins/class-mailchimp-for-woocommerce.php';
222223
include_once NEWSPACK_ABSPATH . 'includes/plugins/class-onesignal.php';
223224
include_once NEWSPACK_ABSPATH . 'includes/plugins/class-organic-profile-block.php';
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
/**
3+
* WP-CLI commands for GA4 custom dimension provisioning.
4+
*
5+
* @package Newspack
6+
*/
7+
8+
namespace Newspack\CLI;
9+
10+
use Newspack\GA4_Custom_Dimensions;
11+
use WP_CLI;
12+
13+
defined( 'ABSPATH' ) || exit;
14+
15+
/**
16+
* Provisions Newspack's standard GA4 custom dimensions.
17+
*/
18+
class GA4_Dimensions {
19+
20+
/**
21+
* Provision Newspack's GA4 custom dimensions on the connected property.
22+
*
23+
* Authenticates via Newspack's Google OAuth (preferred – its tokens carry
24+
* the `analytics.edit` scope) and falls back to Google Site Kit. Without an
25+
* explicit `--user`, it authenticates as the Site Kit module owner, so the
26+
* command works unattended from cron; pass `--user=<admin>` to run as a
27+
* specific administrator instead. Exits non-zero if any dimension could not
28+
* be created.
29+
*
30+
* ## OPTIONS
31+
*
32+
* [--dry-run]
33+
* : Report connection status and available slots without creating anything.
34+
*
35+
* ## EXAMPLES
36+
*
37+
* wp newspack ga4-dimensions provision
38+
* wp newspack ga4-dimensions provision --dry-run
39+
* wp --user=admin newspack ga4-dimensions provision
40+
*
41+
* @param array $args Positional args.
42+
* @param array $assoc_args Flags.
43+
*/
44+
public function provision( $args, $assoc_args ) {
45+
$dry_run = ! empty( $assoc_args['dry-run'] );
46+
47+
if ( $dry_run ) {
48+
$status = GA4_Custom_Dimensions::status();
49+
if ( is_wp_error( $status ) ) {
50+
WP_CLI::error( $status->get_error_message() );
51+
}
52+
WP_CLI::log( 'GA4 dimension provisioning status:' );
53+
WP_CLI::log( sprintf( ' Property ID: %s', $status['property_id'] ) );
54+
WP_CLI::log( sprintf( ' Site Kit connected: %s', $status['site_kit_connected'] ? 'yes' : 'no' ) );
55+
WP_CLI::log( sprintf( ' Auth source: %s', $status['auth_source'] ?? 'unknown' ) );
56+
WP_CLI::log( sprintf( ' Event-scoped existing: %d', $status['event_scoped_existing'] ) );
57+
WP_CLI::log( sprintf( ' Newspack dimensions: %d total, %d present, %d missing', $status['newspack_total'], count( $status['newspack_present'] ), count( $status['newspack_missing'] ) ) );
58+
if ( $status['newspack_missing'] ) {
59+
WP_CLI::log( ' Missing: ' . implode( ', ', $status['newspack_missing'] ) );
60+
}
61+
return;
62+
}
63+
64+
$result = GA4_Custom_Dimensions::provision();
65+
if ( is_wp_error( $result ) ) {
66+
WP_CLI::error( $result->get_error_message() );
67+
}
68+
GA4_Custom_Dimensions::maybe_schedule_recheck();
69+
WP_CLI::log( sprintf( 'Property ID: %s', $result['property_id'] ) );
70+
WP_CLI::log( sprintf( 'Auth source: %s', $result['auth_source'] ?? 'unknown' ) );
71+
WP_CLI::log( sprintf( 'Created: %d (%s)', count( $result['created'] ), implode( ', ', $result['created'] ) ) );
72+
WP_CLI::log( sprintf( 'Already existed: %d', count( $result['skipped_exists'] ) ) );
73+
if ( ! empty( $result['errors'] ) ) {
74+
WP_CLI::warning( 'Errors:' );
75+
foreach ( $result['errors'] as $name => $message ) {
76+
WP_CLI::log( " $name: $message" );
77+
}
78+
WP_CLI::error( sprintf( '%d dimension(s) could not be created.', count( $result['errors'] ) ) );
79+
}
80+
WP_CLI::success( 'GA4 dimension provisioning complete.' );
81+
}
82+
}

includes/cli/class-initializer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public static function init() {
2929
include_once NEWSPACK_ABSPATH . 'includes/cli/class-mailchimp.php';
3030
include_once NEWSPACK_ABSPATH . 'includes/cli/class-optional-modules.php';
3131
include_once NEWSPACK_ABSPATH . 'includes/cli/class-woocommerce-subscriptions.php';
32+
include_once NEWSPACK_ABSPATH . 'includes/cli/class-ga4-dimensions.php';
3233
}
3334

3435
/**
@@ -78,6 +79,7 @@ public static function register_comands() {
7879
WP_CLI::add_command( 'newspack migrate-co-authors-guest-authors', [ 'Newspack\CLI\Co_Authors_Plus', 'migrate_guest_authors' ] );
7980
WP_CLI::add_command( 'newspack backfill-non-editing-contributors', [ 'Newspack\CLI\Co_Authors_Plus', 'backfill_non_editing_contributor' ] );
8081
WP_CLI::add_command( 'newspack migrate-expired-subscriptions', [ 'Newspack\CLI\WooCommerce_Subscriptions', 'migrate_expired_subscriptions' ] );
82+
WP_CLI::add_command( 'newspack ga4-dimensions', 'Newspack\CLI\GA4_Dimensions' );
8183

8284
Optional_Modules::register_commands();
8385
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<?php
2+
/**
3+
* GA4 Admin API client backed by Newspack's Google OAuth credentials.
4+
*
5+
* Mirrors the subset of the GA4 Admin API surface used by
6+
* GA4_Custom_Dimensions (list + create custom dimensions), but calls
7+
* analyticsadmin.googleapis.com directly with a Bearer token obtained
8+
* through Newspack's own OAuth proxy rather than delegating to Site Kit.
9+
*
10+
* @package Newspack
11+
*/
12+
13+
namespace Newspack;
14+
15+
defined( 'ABSPATH' ) || exit;
16+
17+
/**
18+
* Newspack-OAuth-backed GA4 Admin API client.
19+
*/
20+
final class Google_OAuth_GA4_Client {
21+
const BASE_URL = 'https://analyticsadmin.googleapis.com/v1beta';
22+
23+
/**
24+
* OAuth scope required to create GA4 custom dimensions via the Admin API.
25+
*/
26+
const EDIT_SCOPE = 'https://www.googleapis.com/auth/analytics.edit';
27+
28+
/**
29+
* OAuth access token.
30+
*
31+
* @var string
32+
*/
33+
private $access_token;
34+
35+
/**
36+
* Constructor.
37+
*
38+
* @param string $access_token OAuth access token.
39+
*/
40+
private function __construct( $access_token ) {
41+
$this->access_token = $access_token;
42+
}
43+
44+
/**
45+
* Build a client using Newspack's saved Google OAuth credentials.
46+
*
47+
* Returns null if the OAuth proxy is not configured, no credentials are
48+
* saved, or the token can't be resolved. Callers should treat null as a
49+
* signal to fall back to another auth route.
50+
*
51+
* @return self|null
52+
*/
53+
public static function build() {
54+
if ( ! class_exists( __NAMESPACE__ . '\\Google_OAuth' ) ) {
55+
return null;
56+
}
57+
if ( ! Google_OAuth::is_oauth_configured() ) {
58+
return null;
59+
}
60+
$credentials = Google_OAuth::get_oauth2_credentials();
61+
if ( ! $credentials ) {
62+
return null;
63+
}
64+
$token = $credentials->getAccessToken();
65+
if ( empty( $token ) ) {
66+
return null;
67+
}
68+
return new self( $token );
69+
}
70+
71+
/**
72+
* Whether Newspack's stored Google OAuth token currently carries the
73+
* `analytics.edit` scope. Tokens issued before that scope was added to
74+
* Google_OAuth::REQUIRED_SCOPES, or after a publisher revoked it, will not –
75+
* in which case the Admin API rejects writes with a 403 and callers should
76+
* fall back to another auth route rather than this client.
77+
*
78+
* @return bool
79+
*/
80+
public static function has_edit_scope() {
81+
return class_exists( __NAMESPACE__ . '\\Google_OAuth' )
82+
&& Google_OAuth::token_has_scope( self::EDIT_SCOPE );
83+
}
84+
85+
/**
86+
* List all custom dimensions on a GA4 property.
87+
*
88+
* @param string $property_id GA4 property ID (numeric, no "properties/" prefix).
89+
* @return array<int,array{name:string,parameterName:string,displayName:string,scope:string}>
90+
* @throws \RuntimeException On HTTP or API error.
91+
*/
92+
public function list_custom_dimensions( $property_id ) {
93+
$dimensions = [];
94+
$page_token = null;
95+
do {
96+
$url = self::BASE_URL . '/properties/' . rawurlencode( $property_id ) . '/customDimensions';
97+
if ( $page_token ) {
98+
$url = add_query_arg( 'pageToken', $page_token, $url );
99+
}
100+
$body = $this->request( 'GET', $url );
101+
$items = isset( $body['customDimensions'] ) && is_array( $body['customDimensions'] ) ? $body['customDimensions'] : [];
102+
foreach ( $items as $dimension ) {
103+
$dimensions[] = [
104+
'name' => isset( $dimension['name'] ) ? $dimension['name'] : '',
105+
'parameterName' => isset( $dimension['parameterName'] ) ? $dimension['parameterName'] : '',
106+
'displayName' => isset( $dimension['displayName'] ) ? $dimension['displayName'] : '',
107+
'scope' => isset( $dimension['scope'] ) ? $dimension['scope'] : '',
108+
];
109+
}
110+
$page_token = isset( $body['nextPageToken'] ) ? $body['nextPageToken'] : null;
111+
} while ( $page_token );
112+
return $dimensions;
113+
}
114+
115+
/**
116+
* Create an event-scoped custom dimension on a GA4 property.
117+
*
118+
* @param string $property_id GA4 property ID.
119+
* @param string $parameter_name Event parameter name.
120+
* @param string $display_name Display name shown in the GA4 UI.
121+
* @return array Decoded API response.
122+
* @throws \RuntimeException On HTTP or API error.
123+
*/
124+
public function create_custom_dimension( $property_id, $parameter_name, $display_name ) {
125+
$url = self::BASE_URL . '/properties/' . rawurlencode( $property_id ) . '/customDimensions';
126+
$payload = [
127+
'parameterName' => $parameter_name,
128+
'displayName' => $display_name,
129+
'scope' => 'EVENT',
130+
];
131+
return $this->request( 'POST', $url, $payload );
132+
}
133+
134+
/**
135+
* Issue an authenticated request to the Analytics Admin API.
136+
*
137+
* @param string $method HTTP method.
138+
* @param string $url Full URL.
139+
* @param array|null $body Optional JSON body.
140+
* @return array Decoded response.
141+
* @throws \RuntimeException On HTTP or API error.
142+
*/
143+
private function request( $method, $url, $body = null ) {
144+
$args = [
145+
'method' => $method,
146+
'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout
147+
'headers' => [
148+
'Authorization' => 'Bearer ' . $this->access_token,
149+
'Accept' => 'application/json',
150+
],
151+
];
152+
if ( null !== $body ) {
153+
$args['headers']['Content-Type'] = 'application/json';
154+
$args['body'] = wp_json_encode( $body );
155+
}
156+
$response = wp_remote_request( $url, $args );
157+
if ( is_wp_error( $response ) ) {
158+
throw new \RuntimeException( esc_html( $response->get_error_message() ) );
159+
}
160+
$code = wp_remote_retrieve_response_code( $response );
161+
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
162+
if ( $code < 200 || $code >= 300 ) {
163+
$message = is_array( $decoded ) && isset( $decoded['error']['message'] )
164+
? $decoded['error']['message']
165+
: 'HTTP ' . $code;
166+
throw new \RuntimeException( esc_html( "Analytics Admin API error ($code): $message" ) );
167+
}
168+
return is_array( $decoded ) ? $decoded : [];
169+
}
170+
}

includes/oauth/class-google-oauth.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,38 @@ public static function validate_token_and_get_email_address( $access_token, $req
367367
}
368368
}
369369

370+
/**
371+
* Whether the saved Newspack Google OAuth token currently carries a given scope.
372+
*
373+
* Queries Google's tokeninfo endpoint. Returns false on any failure – no saved
374+
* credentials, network error, or the scope simply being absent – so callers can
375+
* treat a false result as "do not rely on this scope".
376+
*
377+
* @param string $scope Full scope URL, e.g. 'https://www.googleapis.com/auth/analytics.edit'.
378+
* @return bool
379+
*/
380+
public static function token_has_scope( $scope ) {
381+
$credentials = self::get_oauth2_credentials();
382+
if ( false === $credentials ) {
383+
return false;
384+
}
385+
$token_info_response = wp_safe_remote_get(
386+
add_query_arg(
387+
'access_token',
388+
$credentials->getAccessToken(),
389+
'https://www.googleapis.com/oauth2/v1/tokeninfo'
390+
)
391+
);
392+
if ( 200 !== wp_remote_retrieve_response_code( $token_info_response ) ) {
393+
return false;
394+
}
395+
$token_info = json_decode( wp_remote_retrieve_body( $token_info_response ) );
396+
if ( ! isset( $token_info->scope ) ) {
397+
return false;
398+
}
399+
return in_array( $scope, explode( ' ', $token_info->scope ), true );
400+
}
401+
370402
/**
371403
* Authenticated user's basic information.
372404
*

0 commit comments

Comments
 (0)