Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.31% covered (warning)
82.31%
121 / 147
35.71% covered (danger)
35.71%
5 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tracks
82.31% covered (warning)
82.31%
121 / 147
35.71% covered (danger)
35.71%
5 / 14
93.59
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 record_episode_published
83.33% covered (warning)
83.33%
30 / 36
0.00% covered (danger)
0.00%
0 / 1
17.19
 record_media_uploaded
80.00% covered (warning)
80.00%
16 / 20
0.00% covered (danger)
0.00%
0 / 1
11.97
 record_category_added
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 record_category_updated
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 record_show_url_added
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 record_show_url_updated
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 maybe_record_show_url_addition
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
9.80
 record_settings_saved
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 maybe_record_status_change
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
8.05
 identity_for_post
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
 has_podcast_media
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_first_episode_for_site
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 record_event
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
10.50
1<?php
2/**
3 * Tracks instrumentation for Jetpack Podcast.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8declare( strict_types = 1 );
9
10namespace Automattic\Jetpack\Podcast;
11
12use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
14use Throwable;
15use WP_Post;
16use WP_Query;
17use WP_User;
18
19/**
20 * Records podcast lifecycle events. Event names stay `wpcom_*` so analytics
21 * queries cover Simple, Atomic, and self-hosted Jetpack feeds without a rewrite.
22 * Dispatch tries `tracks_record_event` (Simple) and falls back to
23 * `\Automattic\Jetpack\Tracking::tracks_record_event` (Atomic). Neither is a
24 * hard dep — silently no-ops when neither is reachable.
25 */
26class Tracks {
27
28    /**
29     * Wire the recorder hooks.
30     */
31    public static function init(): void {
32        // `wp_after_insert_post` runs after terms + meta are saved — required
33        // because Gutenberg/REST publishes set terms after `transition_post_status`.
34        add_action( 'wp_after_insert_post', array( __CLASS__, 'record_episode_published' ), 10, 4 );
35
36        add_action( 'add_attachment', array( __CLASS__, 'record_media_uploaded' ) );
37
38        add_action( 'add_option_podcasting_category_id', array( __CLASS__, 'record_category_added' ), 10, 2 );
39        add_action( 'update_option_podcasting_category_id', array( __CLASS__, 'record_category_updated' ), 10, 3 );
40
41        add_action( 'add_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_added' ), 10, 2 );
42        add_action( 'update_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_updated' ), 10, 3 );
43
44        add_action( 'jetpack_podcast_settings_saved', array( __CLASS__, 'record_settings_saved' ) );
45    }
46
47    /**
48     * Emit `wpcom_podcast_episode_published` (and `wpcom_podcast_show_launched`
49     * once per site) when a podcast-category post enters `publish`.
50     *
51     * @param int          $post_id     Post ID.
52     * @param WP_Post|null $post        Post object.
53     * @param bool         $update      Whether this is an update.
54     * @param WP_Post|null $post_before Previous post state.
55     */
56    public static function record_episode_published( $post_id, $post, $update, $post_before ): void {
57        unset( $post_id, $update );
58
59        try {
60            if ( ! $post instanceof WP_Post ) {
61                return;
62            }
63
64            if ( 'publish' !== $post->post_status ) {
65                return;
66            }
67
68            if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) {
69                return;
70            }
71
72            if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
73                return;
74            }
75
76            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
77            if ( function_exists( 'is_headstart_post' ) && is_headstart_post( $post ) ) {
78                return;
79            }
80
81            if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
82                return;
83            }
84
85            $category_id = Customize_Feed::resolve_category_id();
86            if ( 0 === $category_id ) {
87                return;
88            }
89
90            if ( ! in_category( $category_id, $post ) ) {
91                return;
92            }
93
94            // Match the RSS feed's definition of an episode — must carry
95            // audio, not just sit in the podcast category.
96            if ( ! self::has_podcast_media( $post ) ) {
97                return;
98            }
99
100            $is_first = self::is_first_episode_for_site( $category_id, (int) $post->ID );
101
102            self::record_event(
103                'wpcom_podcast_episode_published',
104                array(
105                    'post_id'                   => (int) $post->ID,
106                    'is_first_episode_for_site' => $is_first,
107                ),
108                self::identity_for_post( $post )
109            );
110
111            // Atomic INSERT — only one concurrent caller per site wins, so
112            // `show_launched` fires exactly once per site.
113            if ( $is_first && add_option( 'podcast_show_launched_tracked', time(), '', false ) ) {
114                self::record_event(
115                    'wpcom_podcast_show_launched',
116                    array( 'post_id' => (int) $post->ID ),
117                    self::identity_for_post( $post )
118                );
119            }
120        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
121            // Tracks is best-effort — never break a publish.
122        }
123    }
124
125    /**
126     * Emit `wpcom_podcast_media_uploaded` for audio/video attachments on a
127     * podcasting-enabled site.
128     *
129     * @param int $attachment_id Attachment post ID.
130     */
131    public static function record_media_uploaded( $attachment_id ): void {
132        try {
133            if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
134                return;
135            }
136
137            $attachment = get_post( (int) $attachment_id );
138            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
139            if ( $attachment && function_exists( 'is_headstart_post' ) && is_headstart_post( $attachment ) ) {
140                return;
141            }
142
143            if ( 0 === Customize_Feed::resolve_category_id() ) {
144                return;
145            }
146
147            $mime_type = (string) get_post_mime_type( (int) $attachment_id );
148            if ( '' === $mime_type ) {
149                return;
150            }
151            if ( 0 !== strpos( $mime_type, 'audio/' ) && 0 !== strpos( $mime_type, 'video/' ) ) {
152                return;
153            }
154
155            self::record_event(
156                'wpcom_podcast_media_uploaded',
157                array(
158                    'attachment_id' => (int) $attachment_id,
159                    'mime_type'     => $mime_type,
160                )
161            );
162        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
163            // Tracks is best-effort.
164        }
165    }
166
167    /**
168     * `add_option_podcasting_category_id` callback — first-ever write of the
169     * option (previous value treated as 0).
170     *
171     * @param string $option Option name.
172     * @param mixed  $value  Newly stored value.
173     */
174    public static function record_category_added( $option, $value ): void {
175        unset( $option );
176
177        try {
178            self::maybe_record_status_change( 0, (int) $value );
179        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
180            // Tracks is best-effort.
181        }
182    }
183
184    /**
185     * `update_option_podcasting_category_id` callback — every change to an
186     * existing row.
187     *
188     * @param mixed  $old_value Previous stored value.
189     * @param mixed  $value     Newly stored value.
190     * @param string $option    Option name.
191     */
192    public static function record_category_updated( $old_value, $value, $option ): void {
193        unset( $option );
194
195        try {
196            self::maybe_record_status_change( (int) $old_value, (int) $value );
197        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
198            // Tracks is best-effort.
199        }
200    }
201
202    /**
203     * `add_option_podcasting_show_urls` callback. No prior row exists, so
204     * every entry is a first-time entry.
205     *
206     * @param string $option    Option name.
207     * @param mixed  $new_value Newly stored value.
208     */
209    public static function record_show_url_added( $option, $new_value ): void {
210        unset( $option );
211        self::maybe_record_show_url_addition( array(), $new_value );
212    }
213
214    /**
215     * `update_option_podcasting_show_urls` callback. Compare the new value
216     * against the prior array to find the first directory that transitioned
217     * from absent/empty to a non-empty URL.
218     *
219     * @param mixed  $old_value Previous stored value (expected: array).
220     * @param mixed  $new_value Newly stored value.
221     * @param string $option    Option name.
222     */
223    public static function record_show_url_updated( $old_value, $new_value, $option ): void {
224        unset( $option );
225        self::maybe_record_show_url_addition( is_array( $old_value ) ? $old_value : array(), $new_value );
226    }
227
228    /**
229     * Emit `wpcom_podcasting_show_url_saved` for the first podcatcher key
230     * that transitions from absent/empty to a non-empty string.
231     *
232     * @param array $old_value Previous map of directory => url.
233     * @param mixed $new_value Newly stored value.
234     */
235    private static function maybe_record_show_url_addition( array $old_value, $new_value ): void {
236        try {
237            if ( ! is_array( $new_value ) ) {
238                return;
239            }
240
241            foreach ( $new_value as $app => $url ) {
242                if ( ! is_string( $url ) || '' === $url ) {
243                    continue;
244                }
245
246                $previous = isset( $old_value[ $app ] ) && is_string( $old_value[ $app ] ) ? $old_value[ $app ] : '';
247                if ( '' !== $previous ) {
248                    continue;
249                }
250
251                self::record_event(
252                    'wpcom_podcasting_show_url_saved',
253                    array( 'app' => (string) $app )
254                );
255                return;
256            }
257        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
258            // Tracks is best-effort.
259        }
260    }
261
262    /**
263     * Emit `wpcom_podcasting_settings_saved` after a podcast settings write.
264     *
265     * Fired off the `jetpack_podcast_settings_saved` action that
266     * {@see Podcast_Settings_Endpoint::update_item()} triggers, so it's agnostic
267     * to the REST transport — the endpoint already gates on a saved option.
268     */
269    public static function record_settings_saved(): void {
270        try {
271            // Skip user-supplied free-text fields — keep PII out of tracks.
272            $pii   = array( 'podcasting_email', 'podcasting_talent_name' );
273            $state = array();
274            foreach ( Settings::OPTION_NAMES as $name ) {
275                if ( in_array( $name, $pii, true ) ) {
276                    continue;
277                }
278                $state[ $name ] = get_option( $name, '' );
279            }
280            self::record_event( 'wpcom_podcasting_settings_saved', $state );
281        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
282            // Tracks is best-effort.
283        }
284    }
285
286    /**
287     * Emit `wpcom_podcasting_status_changed` (enabled / disabled / changed)
288     * when the `podcasting_category_id` option transitions.
289     *
290     * @param int $old_value Previous category ID (0 == disabled).
291     * @param int $new_value New category ID (0 == disabled).
292     */
293    private static function maybe_record_status_change( int $old_value, int $new_value ): void {
294        if ( $old_value === $new_value ) {
295            return;
296        }
297
298        if ( 0 === $old_value && 0 !== $new_value ) {
299            $status = 'enabled';
300        } elseif ( 0 !== $old_value && 0 === $new_value ) {
301            $status = 'disabled';
302        } else {
303            $status = 'changed';
304        }
305
306        // `WPCOM_Store_API` on Simple, `Current_Plan` on Atomic — same dual
307        // pattern as `Masterbar\Dashboard_Switcher_Tracking::get_plan()`.
308        $plan = class_exists( '\WPCOM_Store_API' )
309            ? \WPCOM_Store_API::get_current_plan( (int) get_current_blog_id() )
310            : ( class_exists( '\Automattic\Jetpack\Current_Plan' ) ? \Automattic\Jetpack\Current_Plan::get() : array() );
311
312        self::record_event(
313            'wpcom_podcasting_status_changed',
314            array(
315                'status'               => $status,
316                'surface'              => 'option_write',
317                'previous_category_id' => $old_value,
318                'new_category_id'      => $new_value,
319                'user_id'              => (int) get_current_user_id(),
320                'product_slug'         => (string) ( $plan['product_slug'] ?? '' ),
321            )
322        );
323
324        /** This action is documented in projects/packages/forms/src/contact-form/class-util.php */
325        do_action( 'jetpack_bump_stats_extras', 'wpcom-podcasting-status', $status );
326    }
327
328    /**
329     * Identity for the publish event. Scheduled/cron publishes have no
330     * logged-in user — fall back to the post author.
331     *
332     * @param WP_Post $post Post being published.
333     */
334    private static function identity_for_post( WP_Post $post ): WP_User {
335        if ( ! empty( $post->post_author ) ) {
336            $user = get_userdata( (int) $post->post_author );
337            if ( $user instanceof WP_User ) {
338                return $user;
339            }
340        }
341        return wp_get_current_user();
342    }
343
344    /**
345     * Filters out posts in the podcast category that aren't actually episodes.
346     * `core/audio` block + classic-editor attached audio cover the supported
347     * authoring paths.
348     *
349     * @param WP_Post $post Post being checked.
350     */
351    private static function has_podcast_media( WP_Post $post ): bool {
352        return has_block( 'core/audio', $post )
353            || ! empty( get_attached_media( 'audio', $post->ID ) );
354    }
355
356    /**
357     * True when no other published post exists in the podcast category.
358     *
359     * @param int $category_id     Configured podcast category ID.
360     * @param int $current_post_id Post being published (excluded from the check).
361     */
362    private static function is_first_episode_for_site( int $category_id, int $current_post_id ): bool {
363        $existing = new WP_Query(
364            array(
365                'post_status'      => 'publish',
366                'post_type'        => 'post',
367                'cat'              => $category_id,
368                'post__not_in'     => array( $current_post_id ),
369                'posts_per_page'   => 1,
370                'fields'           => 'ids',
371                'no_found_rows'    => true,
372                'suppress_filters' => true,
373            )
374        );
375
376        return empty( $existing->posts );
377    }
378
379    /**
380     * Dispatch a tracks event. Auto-injects `blog_id` and defaults `$user`
381     * to the current user.
382     *
383     * @param string       $event_name Tracks event name.
384     * @param array        $properties Event properties.
385     * @param WP_User|null $user       Identity override; defaults to current user.
386     * @return mixed
387     */
388    private static function record_event( string $event_name, array $properties, ?WP_User $user = null ) {
389        try {
390            $user                  = $user ?? wp_get_current_user();
391            $properties['blog_id'] = (int) Connection_Manager::get_site_id( true );
392
393            if ( ! function_exists( 'tracks_record_event' ) && function_exists( 'require_lib' ) ) {
394                require_lib( 'tracks/client' );
395            }
396
397            if ( function_exists( 'tracks_record_event' ) ) {
398                return tracks_record_event( $user, $event_name, $properties );
399            }
400
401            if ( class_exists( '\Automattic\Jetpack\Tracking' ) ) {
402                return ( new \Automattic\Jetpack\Tracking() )->tracks_record_event( $user, $event_name, $properties );
403            }
404        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
405            // Tracks is best-effort.
406        }
407
408        return null;
409    }
410}