Skip to content

Commit ea13acc

Browse files
Merge pull request #4748 from Automattic/trunk
Alpha release May 21
2 parents 614d837 + 1f6f512 commit ea13acc

23 files changed

Lines changed: 4043 additions & 957 deletions

composer.lock

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

includes/class-alert-manager.php

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,85 @@ public static function init() {
9696
add_action( 'newspack_sync_retry_exhausted', [ __CLASS__, 'handle_sync_retry_exhausted' ] );
9797
add_action( 'newspack_data_event_retry_exhausted', [ __CLASS__, 'handle_data_event_retry_exhausted' ] );
9898
add_action( 'newspack_integration_health_check_failed', [ __CLASS__, 'handle_health_check_failed' ] );
99+
add_action( 'newspack_alert', [ __CLASS__, 'forward_alert_to_log' ] );
99100
add_action( self::PATTERN_SCAN_HOOK, [ __CLASS__, 'scan_failure_patterns' ] );
100101
add_action( 'init', [ __CLASS__, 'schedule_pattern_scan' ] );
101102
}
102103

104+
/**
105+
* Forward a `newspack_alert` to the `newspack_log` action so Newspack
106+
* Manager's Logger routes it. Severity drives the destination:
107+
*
108+
* - severity = 'error' or 'critical' → type 'error', log_level 3
109+
* (Alert — Slack)
110+
* - anything else (incl. 'warning', unknown, or missing severity) →
111+
* type 'debug', log_level 2 (Watch — logstash only)
112+
*
113+
* Only known error severities escalate to Slack so an unanticipated
114+
* alert shape (e.g. a third-party `newspack_alert` with no severity)
115+
* lands in Watch rather than paging on-call.
116+
*
117+
* Only the human-readable `message` is forwarded as free text. Any
118+
* contact email carried in the alert `context` is passed through
119+
* Logger's first-class `user_email` param — a structured field that is
120+
* not part of the Slack message body — instead of being interpolated
121+
* into `message`. The rest of the `context` is intentionally dropped to
122+
* avoid leaking source payloads into downstream logs.
123+
*
124+
* When Newspack Manager isn't active, `newspack_log` is a no-op.
125+
*
126+
* @param mixed $alert The alert payload fired by this class.
127+
*/
128+
public static function forward_alert_to_log( $alert ) {
129+
if ( ! is_array( $alert ) || ! isset( $alert['message'] ) || ! is_scalar( $alert['message'] ) || '' === (string) $alert['message'] ) {
130+
return;
131+
}
132+
133+
$code = is_scalar( $alert['type'] ?? null ) && '' !== (string) $alert['type']
134+
? (string) $alert['type']
135+
: 'newspack_alert';
136+
137+
$severity = is_scalar( $alert['severity'] ?? null ) ? (string) $alert['severity'] : '';
138+
$is_error = in_array( $severity, [ 'error', 'critical' ], true );
139+
140+
$params = [
141+
'type' => $is_error ? 'error' : 'debug',
142+
'log_level' => $is_error ? 3 : 2,
143+
];
144+
145+
$user_email = self::get_alert_user_email( $alert );
146+
if ( '' !== $user_email ) {
147+
$params['user_email'] = $user_email;
148+
}
149+
150+
do_action( 'newspack_log', $code, (string) $alert['message'], $params );
151+
}
152+
153+
/**
154+
* Extract the contact email (if any) carried in an alert's `context` so
155+
* it can be forwarded via Logger's structured `user_email` param rather
156+
* than interpolated into the human-readable message.
157+
*
158+
* @param array $alert The alert payload.
159+
*
160+
* @return string The contact email, or '' when none is present.
161+
*/
162+
private static function get_alert_user_email( $alert ) {
163+
$context = is_array( $alert['context'] ?? null ) ? $alert['context'] : [];
164+
165+
// Failure-pattern alerts grouped by contact email carry it as the group value.
166+
if ( 'contact_email' === ( $context['group_by'] ?? '' ) && is_scalar( $context['group_value'] ?? null ) ) {
167+
return (string) $context['group_value'];
168+
}
169+
170+
// Sync/handler exhaustion payloads carry the contact under `contact.email`.
171+
if ( is_array( $context['contact'] ?? null ) && is_scalar( $context['contact']['email'] ?? null ) ) {
172+
return (string) $context['contact']['email'];
173+
}
174+
175+
return '';
176+
}
177+
103178
/**
104179
* Schedule the recurring pattern scan via WP-Cron.
105180
*/
@@ -159,11 +234,13 @@ public static function record_failure( $payload ) {
159234
* @param array $payload Alert data from Contact_Sync.
160235
*/
161236
public static function handle_sync_retry_exhausted( $payload ) {
237+
// The contact email is intentionally left out of the message; it is
238+
// forwarded to the log via Logger's structured `user_email` param
239+
// (see forward_alert_to_log) and remains available in `context`.
162240
$message = sprintf(
163-
'Max retries (%d) reached for integration "%s" sync of %s. Last error: %s',
241+
'Max retries (%d) reached for integration "%s" contact sync. Last error: %s',
164242
$payload['retry_count'] ?? 0,
165243
$payload['integration_id'] ?? 'unknown',
166-
$payload['contact']['email'] ?? 'unknown',
167244
$payload['reason'] ?? 'unknown'
168245
);
169246

@@ -287,11 +364,15 @@ function ( $entry ) use ( $global_cutoff ) {
287364
continue;
288365
}
289366

290-
$message = sprintf(
291-
'Pattern detected: %d failures with %s "%s" in the last %s.',
367+
// When grouping by contact email, keep the email out of the
368+
// message; it is forwarded via Logger's `user_email` param
369+
// (see forward_alert_to_log) and stays in `context`.
370+
$is_email_group = 'contact_email' === $rule['group_by'];
371+
$message = sprintf(
372+
'Pattern detected: %d failures with %s%s in the last %s.',
292373
count( $entries ),
293374
$rule['label'],
294-
$group_value,
375+
$is_email_group ? '' : sprintf( ' "%s"', $group_value ),
295376
self::format_interval( $rule['interval'] )
296377
);
297378

includes/class-donations.php

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,23 @@ public static function get_donation_product_child_products_ids() {
278278
* @return boolean True if a donation product, false if not.
279279
*/
280280
public static function is_donation_product( $product_id ) {
281+
// Check the meta flag first (fast path).
282+
if ( function_exists( 'wc_bool_to_string' ) && get_post_meta( $product_id, WooCommerce_Products::DONATION_FLAG_META_KEY, true ) === wc_bool_to_string( true ) ) {
283+
return true;
284+
}
285+
286+
// Variations inherit the donation flag from their parent (variable / variable-subscription).
287+
if ( function_exists( 'wc_get_product' ) ) {
288+
$product = \wc_get_product( $product_id );
289+
if ( $product && $product->is_type( [ 'variation', 'subscription_variation' ] ) ) {
290+
$parent_id = $product->get_parent_id();
291+
if ( $parent_id && get_post_meta( $parent_id, WooCommerce_Products::DONATION_FLAG_META_KEY, true ) === wc_bool_to_string( true ) ) {
292+
return true;
293+
}
294+
}
295+
}
296+
297+
// Fall back to the legacy parent/child donation product check.
281298
$parent_product = self::get_parent_donation_product();
282299
if ( ! $parent_product ) {
283300
return false;
@@ -286,6 +303,54 @@ public static function is_donation_product( $product_id ) {
286303
return in_array( $product_id, $donation_product_ids, true ) || $product_id === $parent_product->get_id();
287304
}
288305

306+
/**
307+
* Cached list of product IDs flagged as donations.
308+
*
309+
* @var int[]|null
310+
*/
311+
private static $flagged_donation_product_ids = null;
312+
313+
/**
314+
* Get IDs of all products flagged as donations via the _newspack_is_donation meta.
315+
*
316+
* @return int[] Array of product IDs.
317+
*/
318+
public static function get_flagged_donation_product_ids() {
319+
if ( null !== self::$flagged_donation_product_ids ) {
320+
return self::$flagged_donation_product_ids;
321+
}
322+
if ( ! function_exists( 'wc_bool_to_string' ) ) {
323+
return [];
324+
}
325+
$flagged_products = get_posts(
326+
[
327+
'post_type' => 'product',
328+
'post_status' => 'any',
329+
'posts_per_page' => -1,
330+
'fields' => 'ids',
331+
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
332+
[
333+
'key' => WooCommerce_Products::DONATION_FLAG_META_KEY,
334+
'value' => wc_bool_to_string( true ),
335+
],
336+
],
337+
]
338+
);
339+
self::$flagged_donation_product_ids = array_map( 'intval', $flagged_products );
340+
return self::$flagged_donation_product_ids;
341+
}
342+
343+
/**
344+
* Reset the cached list of flagged donation product IDs.
345+
*
346+
* Call this after updating donation-flag product meta in the same request
347+
* or other long-lived process so subsequent lookups reload the current set
348+
* of flagged donation products.
349+
*/
350+
public static function reset_flagged_donation_product_ids_cache() {
351+
self::$flagged_donation_product_ids = null;
352+
}
353+
289354
/**
290355
* Whether the order is a donation.
291356
*
@@ -309,7 +374,7 @@ public static function is_donation_order( $order ) {
309374
* @return int|false The donation product ID or false.
310375
*/
311376
public static function get_order_donation_product_id( $order_id ) {
312-
$donation_products = self::get_donation_product_child_products_ids();
377+
$donation_products = array_merge( self::get_donation_product_child_products_ids(), self::get_flagged_donation_product_ids() );
313378
if ( empty( array_filter( $donation_products ) ) ) {
314379
return;
315380
}
@@ -1072,15 +1137,11 @@ public static function is_donation_cart() {
10721137
if ( ! self::is_platform_wc() ) {
10731138
return false;
10741139
}
1075-
$donation_products_ids = array_values( self::get_donation_product_child_products_ids() );
1076-
if ( empty( $donation_products_ids ) ) {
1077-
return false;
1078-
}
10791140
if ( ! WC()->cart || ! WC()->cart->cart_contents || ! is_array( WC()->cart->cart_contents ) ) {
10801141
return false;
10811142
}
10821143
foreach ( WC()->cart->cart_contents as $prod_in_cart ) {
1083-
if ( isset( $prod_in_cart['product_id'] ) && in_array( $prod_in_cart['product_id'], $donation_products_ids ) ) {
1144+
if ( isset( $prod_in_cart['product_id'] ) && self::is_donation_product( $prod_in_cart['product_id'] ) ) {
10841145
return true;
10851146
}
10861147
}

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: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ 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';
33+
include_once NEWSPACK_ABSPATH . 'includes/cli/class-teams-for-memberships-diagnostics.php';
3234
}
3335

3436
/**
@@ -78,6 +80,16 @@ public static function register_comands() {
7880
WP_CLI::add_command( 'newspack migrate-co-authors-guest-authors', [ 'Newspack\CLI\Co_Authors_Plus', 'migrate_guest_authors' ] );
7981
WP_CLI::add_command( 'newspack backfill-non-editing-contributors', [ 'Newspack\CLI\Co_Authors_Plus', 'backfill_non_editing_contributor' ] );
8082
WP_CLI::add_command( 'newspack migrate-expired-subscriptions', [ 'Newspack\CLI\WooCommerce_Subscriptions', 'migrate_expired_subscriptions' ] );
83+
WP_CLI::add_command( 'newspack ga4-dimensions', 'Newspack\CLI\GA4_Dimensions' );
84+
85+
// Only register the Teams for Memberships diagnostics command on sites where the
86+
// SkyVerge plugin is active. No reason to surface it in `wp help` otherwise.
87+
if ( class_exists( 'WC_Memberships_For_Teams_Loader' ) ) {
88+
WP_CLI::add_command(
89+
'newspack teams-for-memberships diagnostics',
90+
[ 'Newspack\CLI\Teams_For_Memberships_Diagnostics', 'diagnostics' ]
91+
);
92+
}
8193

8294
Optional_Modules::register_commands();
8395
}

0 commit comments

Comments
 (0)