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