PluginProbe
Blocks – Reusable Content, Shortcodes & Site Variables / trunk
Blocks – Reusable Content, Shortcodes & Site Variables vtrunk
26.09.03.15 26.08.31.21 26.08.22.20 26.08.22.22 26.08.22.17 26.08.22.13 26.08.21.19 26.08.07.23 26.07.19.14 26.07.13.21 26.07.13.17 26.07.12.13 026.07.07.21 026.07.05.18 026.06.26.20 026.06.26.21 026.06.08.20 026.05.13.14 026.04.29.10 trunk 026.02.22.22 026.03.16.23 026.04.23.13
blocks / src / Analytics / MeasurementProtocolClient.php

MeasurementProtocolClient.php in Blocks – Reusable Content, Shortcodes & Site Variables trunk, at src/Analytics/MeasurementProtocolClient.php

191 lines 6.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace RenzoJohnson\Blocks\Analytics;
6
7 use RenzoJohnson\Blocks\Security\SecretStore;
8
9 \defined( 'ABSPATH' ) || exit;
10
11 final class MeasurementProtocolClient {
12 public const HOOK = 'blocks_ga4_mp_deliver';
13 public const GROUP = 'blocks';
14 public const MAX_ATTEMPTS = 3;
15
16 private const ENDPOINT = 'https://www.google-analytics.com/mp/collect';
17 private const OPTION_ENABLED = 'blocks_ga4_ecommerce_enabled';
18 private const OPTION_EVENT = 'blocks_ga4_add_to_cart_enabled';
19 private const OPTION_MEASUREMENT = 'blocks_ga4_measurement_id';
20 private const OPTION_SECRET = 'blocks_ga4_mp_api_secret';
21
22 public function __construct(
23 private readonly \Blocks_Settings_Repository $settings,
24 private readonly SecretStore $secret_store,
25 ) {
26 }
27
28 public function register_hooks(): void {
29 \add_action( self::HOOK, $this->deliver( ... ), 10, 5 );
30 }
31
32 /**
33 * @param array<string, mixed> $params Event parameters.
34 */
35 public function queue( string $event_name, array $params ): bool {
36 if (
37 ! $this->settings->get_bool( self::OPTION_ENABLED )
38 || ! $this->settings->get_bool( self::OPTION_EVENT )
39 || '' === $this->measurement_id()
40 || '' === $this->api_secret()
41 || 1 !== \preg_match( '/^[a-z][a-z0-9_]{0,39}$/', $event_name )
42 || ! (bool) \apply_filters( 'blocks_ga4_should_send', true, $event_name )
43 ) {
44 return false;
45 }
46
47 $filtered_params = \apply_filters( 'blocks_ga4_event_params', $params, $event_name );
48 if ( ! \is_array( $filtered_params ) ) {
49 return false;
50 }
51
52 return $this->schedule( array( $event_name, $filtered_params, $this->client_id(), $this->session_id(), 0 ), 0 );
53 }
54
55 /**
56 * @param array<string, mixed> $params Event parameters.
57 */
58 public function deliver( string $event_name, array $params, string $client_id, string $session_id, int $attempt ): void {
59 $measurement_id = $this->measurement_id();
60 $api_secret = $this->api_secret();
61 if ( '' === $measurement_id || '' === $api_secret || 1 !== \preg_match( '/^[a-z][a-z0-9_]{0,39}$/', $event_name ) ) {
62 return;
63 }
64
65 if ( '' !== $session_id ) {
66 $params['session_id'] = $session_id;
67 }
68 $params['engagement_time_msec'] = 1;
69
70 $body = \wp_json_encode(
71 array(
72 'client_id' => $client_id,
73 'events' => array(
74 array(
75 'name' => $event_name,
76 'params' => $params,
77 ),
78 ),
79 )
80 );
81 if ( ! \is_string( $body ) ) {
82 $this->retry_or_fail( $event_name, $params, $client_id, $session_id, $attempt, 'json_encode_failed' );
83 return;
84 }
85
86 $response = \wp_remote_post(
87 \add_query_arg(
88 array(
89 'measurement_id' => $measurement_id,
90 'api_secret' => $api_secret,
91 ),
92 self::ENDPOINT
93 ),
94 array(
95 'timeout' => 5,
96 'blocking' => true,
97 'headers' => array( 'Content-Type' => 'application/json' ),
98 'body' => $body,
99 )
100 );
101
102 $status = \is_wp_error( $response ) ? 0 : \wp_remote_retrieve_response_code( $response );
103 if ( 200 <= $status && 300 > $status ) {
104 return;
105 }
106
107 $error_code = \is_wp_error( $response ) ? $response->get_error_code() : $status;
108 $this->retry_or_fail( $event_name, $params, $client_id, $session_id, $attempt, $error_code );
109 }
110
111 /**
112 * @param array<int, mixed> $args Hook arguments.
113 */
114 private function schedule( array $args, int $delay ): bool {
115 if ( 0 === $delay && \function_exists( 'as_enqueue_async_action' ) ) {
116 $action_id = \as_enqueue_async_action( self::HOOK, $args, self::GROUP, false, 10 );
117 if ( \is_int( $action_id ) && 0 < $action_id ) {
118 return true;
119 }
120 }
121
122 if ( 0 < $delay && \function_exists( 'as_schedule_single_action' ) ) {
123 $action_id = \as_schedule_single_action( \time() + $delay, self::HOOK, $args, self::GROUP, false, 10 );
124 if ( \is_int( $action_id ) && 0 < $action_id ) {
125 return true;
126 }
127 }
128
129 return \wp_schedule_single_event( \time() + \max( 1, $delay ), self::HOOK, $args );
130 }
131
132 /**
133 * @param array<string, mixed> $params Event parameters.
134 */
135 private function retry_or_fail( string $event_name, array $params, string $client_id, string $session_id, int $attempt, int|string $error_code ): void {
136 if ( self::MAX_ATTEMPTS <= $attempt ) {
137 \do_action( 'blocks_ga4_delivery_failed', $event_name, $attempt, $error_code );
138 return;
139 }
140
141 $next_attempt = $attempt + 1;
142 $delays = array(
143 1 => 60,
144 2 => 120,
145 3 => 240,
146 );
147 if ( ! $this->schedule( array( $event_name, $params, $client_id, $session_id, $next_attempt ), $delays[ $next_attempt ] ) ) {
148 \do_action( 'blocks_ga4_delivery_failed', $event_name, $attempt, 'schedule_failed' );
149 }
150 }
151
152 private function measurement_id(): string {
153 $measurement_id = \strtoupper( \trim( $this->settings->get_string( self::OPTION_MEASUREMENT ) ) );
154 return 1 === \preg_match( '/^G-[A-Z0-9]+$/', $measurement_id ) ? $measurement_id : '';
155 }
156
157 private function api_secret(): string {
158 return \trim( $this->secret_store->get( self::OPTION_SECRET ) );
159 }
160
161 private function client_id(): string {
162 // phpcs:ignore WordPress.Security.NonceVerification.NoNonceVerification -- First-party analytics cookie, not a form submission.
163 $raw = isset( $_COOKIE['_ga'] ) ? \sanitize_text_field( \wp_unslash( $_COOKIE['_ga'] ) ) : '';
164 if ( '' !== $raw ) {
165 $parts = \explode( '.', $raw );
166 $count = \count( $parts );
167 if ( 4 <= $count ) {
168 $client_id = $parts[ $count - 2 ] . '.' . $parts[ $count - 1 ];
169 if ( 1 === \preg_match( '/^\d+\.\d+$/', $client_id ) ) {
170 return $client_id;
171 }
172 }
173 }
174
175 return \sprintf( '%d.%d', \wp_rand( 100000000, 999999999 ), \time() );
176 }
177
178 private function session_id(): string {
179 $measurement_id = $this->measurement_id();
180 $cookie_name = '_ga_' . \substr( $measurement_id, 2 );
181 // phpcs:ignore WordPress.Security.NonceVerification.NoNonceVerification -- First-party analytics cookie, not a form submission.
182 $raw = isset( $_COOKIE[ $cookie_name ] ) ? \sanitize_text_field( \wp_unslash( $_COOKIE[ $cookie_name ] ) ) : '';
183 if ( '' === $raw ) {
184 return '';
185 }
186
187 $parts = \explode( '.', $raw );
188 return isset( $parts[2] ) && 1 === \preg_match( '/^\d+$/', $parts[2] ) ? $parts[2] : '';
189 }
190 }
191