$payload Event properties. * @return void */ private static function record( string $event_name, array $payload ): void { try { if ( null !== self::$event_recorder ) { ( self::$event_recorder )( $event_name, $payload ); return; } WC_Tracks::record_event( $event_name, $payload ); } catch ( \Throwable $e ) { unset( $e ); } } /** * Record `_update_available` for a post that just transitioned into * `core_updated_customized` at a newer `template_version_to` than was last * stamped on the post. * * Callers must already have confirmed that the new status is * `core_updated_customized` and that the version-meta advance has happened * (or is about to). This method only owns the suppress-during-backfill check * and the per-`(post_id, template_version_to)` dedup transient. * * @param int $post_id The `woo_email` post that just became eligible. * @return void * * @since 10.9.0 */ public static function record_update_available( int $post_id ): void { if ( self::should_suppress() ) { return; } $payload = self::build_base_payload( $post_id ); if ( null === $payload ) { return; } $version_to = (string) $payload['template_version_to']; if ( '' === $version_to ) { return; } $transient_key = self::available_dedup_transient_key( $post_id, $version_to ); if ( false !== get_transient( $transient_key ) ) { return; } self::record( self::EVENT_UPDATE_AVAILABLE, $payload ); // Set the dedup transient after the record() call. record() swallows // throws from the Tracks pipeline in production, so in steady state // either ordering would dedup identically. The post-record ordering is // intentional for the testing path: when set_event_recorder() injects a // throwing spy, the transient stays unwritten and a retry remains // possible without manually clearing it. 30-day TTL outlasts the gap // between core releases without leaving stale dedup keys forever. set_transient( $transient_key, 1, MONTH_IN_SECONDS ); } /** * Record `_update_applied` for the auto-applier success path. * * The auto-applier only runs against posts classified as * `core_updated_uncustomized`, so `had_customizations` is statically `false` * and `auto_resolved` is statically `true`. Documented at the call site * rather than computed. * * @param int $post_id The `woo_email` post that was just rewritten with canonical core content. * @return void * * @since 10.9.0 */ public static function record_auto_applied( int $post_id ): void { if ( self::should_suppress() ) { return; } $payload = self::build_base_payload( $post_id ); if ( null === $payload ) { return; } $payload['applied_from'] = self::APPLIED_FROM_AUTO; $payload['auto_resolved'] = true; $payload['had_customizations'] = false; self::record( self::EVENT_UPDATE_APPLIED, $payload ); } /** * Record `_update_applied` for the selective-applier success path (the REST * `/apply` endpoint behind RSM-143's Review drawer). * * The selective applier only runs against `core_updated_customized` posts, * so `had_customizations` is statically `true` and `auto_resolved` is * statically `false`. * * @param int $post_id The `woo_email` post that was just rewritten via selective merge. * @return void * * @since 10.9.0 */ public static function record_selective_applied( int $post_id ): void { if ( self::should_suppress() ) { return; } $payload = self::build_base_payload( $post_id ); if ( null === $payload ) { return; } $payload['applied_from'] = self::APPLIED_FROM_SELECTIVE_REST; $payload['auto_resolved'] = false; $payload['had_customizations'] = true; self::record( self::EVENT_UPDATE_APPLIED, $payload ); } /** * Listener body for `woocommerce_email_template_sync_backfill_complete`. * * Fires `_backfill_completed` once per site. Payload is intentionally * minimal (per-case counters and elapsed_ms would require touching the * 10.8-frozen {@see WCEmailTemplateSyncBackfill} class): * * - `posts_backfilled`: count of `woo_email` posts whose * {@see WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY} flag is true. * - `wc_version`: current WooCommerce version string. * * @return void * * @since 10.9.0 */ public static function on_backfill_complete(): void { // Persistent one-shot guard: `WCEmailTemplateSyncBackfill::run()` // fires `finalize()` on every invocation, and finalize() always // fires `BACKFILL_COMPLETE_ACTION`. In production `run()` only // runs once (10.8 db-update callback), but manual re-runs via // wp-cli or tests can re-fire the action — this gate ensures the // Tracks event still lands at most once per site. if ( 'yes' === get_option( self::BACKFILL_COMPLETED_TRACKED_OPTION, 'no' ) ) { return; } $posts_backfilled = self::count_backfilled_posts(); self::record( self::EVENT_BACKFILL_COMPLETED, array( 'posts_backfilled' => $posts_backfilled, 'wc_version' => function_exists( 'WC' ) && WC() ? (string) WC()->version : '', ) ); update_option( self::BACKFILL_COMPLETED_TRACKED_OPTION, 'yes', false ); } /** * Assemble the shared base payload for one post. * * Returns `null` when the post is not a sync-eligible `woo_email` or when * any inner call throws — every `record_*` caller treats `null` as "skip * this event," keeping the Tracks surface fire-and-forget. * * @param int $post_id The `woo_email` post ID. * @return array|null */ private static function build_base_payload( int $post_id ): ?array { try { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return null; } $posts_manager = WCTransactionalEmailPostsManager::get_instance(); $email_id = (string) $posts_manager->get_email_type_from_post_id( $post_id ); if ( '' === $email_id ) { return null; } $sync_config = WCEmailTemplateSyncRegistry::get_email_sync_config( $email_id ); if ( null === $sync_config ) { return null; } $emails = $posts_manager->get_emails_by_id(); $email = $emails[ $email_id ] ?? null; $source_hash_to = ''; if ( $email instanceof \WC_Email ) { try { $source_hash_to = sha1( WCTransactionalEmailPostsGenerator::compute_canonical_post_content( $email ) ); } catch ( \Throwable $e ) { // Canonical compute can throw on misconfigured templates. The event // is still useful with an empty `source_hash_to` — analytics will // see "site with broken canonical render" as a distinct cohort. $source_hash_to = ''; } } $version_from = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::VERSION_META_KEY, true ); $status = (string) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::STATUS_META_KEY, true ); $was_backfilled = (bool) get_post_meta( $post_id, WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, true ); return array( 'email_id' => $email_id, 'template_version_from' => $version_from, 'template_version_to' => (string) ( $sync_config['version'] ?? '' ), 'source_hash_to' => $source_hash_to, 'classification' => $status, 'was_backfilled' => $was_backfilled, ); } catch ( \Throwable $e ) { unset( $e ); return null; } } /** * Whether per-post telemetry should be suppressed in the current request. * * `true` while the one-time RSM-149 backfill is rewriting post content so the * `_available` / `_applied` events do not storm during the migration. The * single `_backfill_completed` event covers that surface instead. * * @return bool */ private static function should_suppress(): bool { return WCEmailTemplateSyncBackfill::is_backfilling(); } /** * Build the transient option key used by the `_update_available` dedup gate. * * @param int $post_id The post ID. * @param string $version_to The current registry version for the email. * @return string */ private static function available_dedup_transient_key( int $post_id, string $version_to ): string { return self::AVAILABLE_DEDUP_TRANSIENT_PREFIX . $post_id . '_' . md5( $version_to ); } /** * Count `woo_email` posts flagged as backfilled by {@see WCEmailTemplateSyncBackfill}. * * @return int */ private static function count_backfilled_posts(): int { $ids = get_posts( array( 'post_type' => 'woo_email', 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => -1, 'no_found_rows' => true, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded set; sync-registered emails only. array( 'key' => WCEmailTemplateDivergenceDetector::BACKFILLED_META_KEY, 'value' => '1', 'compare' => '=', ), ), ) ); return is_array( $ids ) ? count( $ids ) : 0; } }