Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.11% covered (warning)
62.11%
118 / 190
18.18% covered (danger)
18.18%
4 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tracks
62.11% covered (warning)
62.11%
118 / 190
18.18% covered (danger)
18.18%
4 / 22
498.88
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 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
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
9.45
 record_setting_added
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 record_setting_updated
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_record_setting_first_saved
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 filled_setting_count
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 has_value
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 set_surface_from_route
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 clear_surface
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 platform
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 current_product_slug
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 maybe_record_status_change
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 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
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
9.38
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 Automattic\Jetpack\Status\Host;
16use Throwable;
17use WP_Post;
18use WP_Query;
19use WP_REST_Request;
20use WP_User;
21
22/**
23 * Records podcast lifecycle events. Event names stay `wpcom_*` so analytics
24 * queries cover Simple, Atomic, and self-hosted Jetpack feeds without a rewrite.
25 * Dispatch tries `tracks_record_event` (Simple) and falls back to
26 * `\Automattic\Jetpack\Tracking::tracks_record_event` (Atomic). Neither is a
27 * hard dep — silently no-ops when neither is reachable.
28 */
29class Tracks {
30
31    /**
32     * Mirrors `getValidationIssues()` in
33     * `src/dashboard/hooks/use-validation-issues.ts` — keep the two in step.
34     * Not the directory-submission bar, which also wants an episode and a
35     * conforming cover.
36     *
37     * @var string[]
38     */
39    private const SETUP_OPTIONS = array(
40        'podcasting_category_id',
41        'podcasting_title',
42        'podcasting_summary',
43        'podcasting_talent_name',
44        'podcasting_email',
45        'podcasting_category_1',
46        'podcasting_image',
47    );
48
49    /**
50     * What is driving the option write in flight. Option hooks fire
51     * synchronously inside `update_option()`, so the value set for the
52     * dispatching route is the one the recorder reads.
53     *
54     * @var string
55     */
56    private static $surface = 'programmatic';
57
58    /**
59     * Wire the recorder hooks.
60     */
61    public static function init(): void {
62        // `wp_after_insert_post` runs after terms + meta are saved — required
63        // because Gutenberg/REST publishes set terms after `transition_post_status`.
64        add_action( 'wp_after_insert_post', array( __CLASS__, 'record_episode_published' ), 10, 4 );
65
66        add_action( 'add_attachment', array( __CLASS__, 'record_media_uploaded' ) );
67
68        add_action( 'add_option_podcasting_category_id', array( __CLASS__, 'record_category_added' ), 10, 2 );
69        add_action( 'update_option_podcasting_category_id', array( __CLASS__, 'record_category_updated' ), 10, 3 );
70
71        add_action( 'add_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_added' ), 10, 2 );
72        add_action( 'update_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_updated' ), 10, 3 );
73
74        // The options rather than the endpoint's `jetpack_podcast_settings_saved`,
75        // so setup lands whichever path writes it. `podcasting_category_id`
76        // intentionally overlaps `status_changed` above, which stays canonical
77        // for "site enabled podcasting".
78        foreach ( self::SETUP_OPTIONS as $option ) {
79            add_action( "add_option_{$option}", array( __CLASS__, 'record_setting_added' ), 10, 2 );
80            add_action( "update_option_{$option}", array( __CLASS__, 'record_setting_updated' ), 10, 3 );
81        }
82
83        add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'set_surface_from_route' ), 10, 3 );
84        add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'clear_surface' ) );
85    }
86
87    /**
88     * Emit `wpcom_podcast_episode_published` (and `wpcom_podcast_show_launched`
89     * once per site) when a podcast-category post enters `publish`.
90     *
91     * @param int          $post_id     Post ID.
92     * @param WP_Post|null $post        Post object.
93     * @param bool         $update      Whether this is an update.
94     * @param WP_Post|null $post_before Previous post state.
95     */
96    public static function record_episode_published( $post_id, $post, $update, $post_before ): void {
97        unset( $post_id, $update );
98
99        try {
100            if ( ! $post instanceof WP_Post ) {
101                return;
102            }
103
104            if ( 'publish' !== $post->post_status ) {
105                return;
106            }
107
108            if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) {
109                return;
110            }
111
112            if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
113                return;
114            }
115
116            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
117            if ( function_exists( 'is_headstart_post' ) && is_headstart_post( $post ) ) {
118                return;
119            }
120
121            if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
122                return;
123            }
124
125            $category_id = Customize_Feed::resolve_category_id();
126            if ( 0 === $category_id ) {
127                return;
128            }
129
130            if ( ! in_category( $category_id, $post ) ) {
131                return;
132            }
133
134            // Match the RSS feed's definition of an episode — must carry
135            // audio, not just sit in the podcast category.
136            if ( ! self::has_podcast_media( $post ) ) {
137                return;
138            }
139
140            $is_first = self::is_first_episode_for_site( $category_id, (int) $post->ID );
141
142            self::record_event(
143                'wpcom_podcast_episode_published',
144                array(
145                    'post_id'                   => (int) $post->ID,
146                    'is_first_episode_for_site' => $is_first,
147                ),
148                self::identity_for_post( $post )
149            );
150
151            // Atomic INSERT — only one concurrent caller per site wins, so
152            // `show_launched` fires exactly once per site.
153            if ( $is_first && add_option( 'podcast_show_launched_tracked', time(), '', false ) ) {
154                self::record_event(
155                    'wpcom_podcast_show_launched',
156                    array( 'post_id' => (int) $post->ID ),
157                    self::identity_for_post( $post )
158                );
159            }
160        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
161            // Tracks is best-effort — never break a publish.
162        }
163    }
164
165    /**
166     * Emit `wpcom_podcast_media_uploaded` for audio/video attachments on a
167     * podcasting-enabled site.
168     *
169     * @param int $attachment_id Attachment post ID.
170     */
171    public static function record_media_uploaded( $attachment_id ): void {
172        try {
173            if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
174                return;
175            }
176
177            $attachment = get_post( (int) $attachment_id );
178            // @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
179            if ( $attachment && function_exists( 'is_headstart_post' ) && is_headstart_post( $attachment ) ) {
180                return;
181            }
182
183            if ( 0 === Customize_Feed::resolve_category_id() ) {
184                return;
185            }
186
187            $mime_type = (string) get_post_mime_type( (int) $attachment_id );
188            if ( '' === $mime_type ) {
189                return;
190            }
191            if ( 0 !== strpos( $mime_type, 'audio/' ) && 0 !== strpos( $mime_type, 'video/' ) ) {
192                return;
193            }
194
195            self::record_event(
196                'wpcom_podcast_media_uploaded',
197                array(
198                    'attachment_id' => (int) $attachment_id,
199                    'mime_type'     => $mime_type,
200                )
201            );
202        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
203            // Tracks is best-effort.
204        }
205    }
206
207    /**
208     * `add_option_podcasting_category_id` callback — first-ever write of the
209     * option (previous value treated as 0).
210     *
211     * @param string $option Option name.
212     * @param mixed  $value  Newly stored value.
213     */
214    public static function record_category_added( $option, $value ): void {
215        unset( $option );
216
217        try {
218            self::maybe_record_status_change( 0, (int) $value );
219        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
220            // Tracks is best-effort.
221        }
222    }
223
224    /**
225     * `update_option_podcasting_category_id` callback — every change to an
226     * existing row.
227     *
228     * @param mixed  $old_value Previous stored value.
229     * @param mixed  $value     Newly stored value.
230     * @param string $option    Option name.
231     */
232    public static function record_category_updated( $old_value, $value, $option ): void {
233        unset( $option );
234
235        try {
236            self::maybe_record_status_change( (int) $old_value, (int) $value );
237        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
238            // Tracks is best-effort.
239        }
240    }
241
242    /**
243     * `add_option_podcasting_show_urls` callback. No prior row exists, so
244     * every entry is a first-time entry.
245     *
246     * @param string $option    Option name.
247     * @param mixed  $new_value Newly stored value.
248     */
249    public static function record_show_url_added( $option, $new_value ): void {
250        unset( $option );
251        self::maybe_record_show_url_addition( array(), $new_value );
252    }
253
254    /**
255     * `update_option_podcasting_show_urls` callback. Compare the new value
256     * against the prior array to find the first directory that transitioned
257     * from absent/empty to a non-empty URL.
258     *
259     * @param mixed  $old_value Previous stored value (expected: array).
260     * @param mixed  $new_value Newly stored value.
261     * @param string $option    Option name.
262     */
263    public static function record_show_url_updated( $old_value, $new_value, $option ): void {
264        unset( $option );
265        self::maybe_record_show_url_addition( is_array( $old_value ) ? $old_value : array(), $new_value );
266    }
267
268    /**
269     * Emit `wpcom_podcasting_show_url_saved` for the first podcatcher key
270     * that transitions from absent/empty to a non-empty string.
271     *
272     * @param array $old_value Previous map of directory => url.
273     * @param mixed $new_value Newly stored value.
274     */
275    private static function maybe_record_show_url_addition( array $old_value, $new_value ): void {
276        try {
277            if ( ! is_array( $new_value ) ) {
278                return;
279            }
280
281            foreach ( $new_value as $app => $url ) {
282                if ( ! is_string( $url ) || '' === $url ) {
283                    continue;
284                }
285
286                $previous = isset( $old_value[ $app ] ) && is_string( $old_value[ $app ] ) ? $old_value[ $app ] : '';
287                if ( '' !== $previous ) {
288                    continue;
289                }
290
291                self::record_event(
292                    'wpcom_podcasting_show_url_saved',
293                    array(
294                        'app'     => (string) $app,
295                        'surface' => self::$surface,
296                    )
297                );
298                return;
299            }
300        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
301            // Tracks is best-effort.
302        }
303    }
304
305    /**
306     * `add_option_{$option}` callback for a required setting. No prior row, so
307     * the previous value is empty by definition.
308     *
309     * @param string $option Option name.
310     * @param mixed  $value  Stored value.
311     */
312    public static function record_setting_added( $option, $value ): void {
313        self::maybe_record_setting_first_saved( (string) $option, '', $value );
314    }
315
316    /**
317     * `update_option_{$option}` callback for a required setting. Seldom the live
318     * path: `update_option()` defers to `add_option()` while the stored value
319     * still equals the registered empty default.
320     *
321     * @param mixed  $old_value Previous stored value.
322     * @param mixed  $new_value Newly stored value.
323     * @param string $option    Option name.
324     */
325    public static function record_setting_updated( $old_value, $new_value, $option ): void {
326        self::maybe_record_setting_first_saved( (string) $option, $old_value, $new_value );
327    }
328
329    /**
330     * Emit `wpcom_podcast_setting_first_saved` when a required setting picks up
331     * a value for the first time.
332     *
333     * Recording only the empty → filled transition bounds this at one event per
334     * setting instead of one per save, and keeps sites configured before it
335     * shipped silent rather than reporting a false completion. `is_complete`
336     * marks the transition that finished the set.
337     *
338     * @param string $option    Option name.
339     * @param mixed  $old_value Previous stored value.
340     * @param mixed  $new_value Newly stored value.
341     */
342    private static function maybe_record_setting_first_saved( string $option, $old_value, $new_value ): void {
343        try {
344            if ( self::has_value( $old_value ) || ! self::has_value( $new_value ) ) {
345                return;
346            }
347
348            // Both hooks fire after the write, so this counts the new value too.
349            $filled_count = self::filled_setting_count();
350
351            self::record_event(
352                'wpcom_podcast_setting_first_saved',
353                array(
354                    'setting'      => $option,
355                    'filled_count' => $filled_count,
356                    'is_complete'  => count( self::SETUP_OPTIONS ) === $filled_count,
357                    'surface'      => self::$surface,
358                    'user_id'      => (int) get_current_user_id(),
359                    'product_slug' => self::current_product_slug(),
360                )
361            );
362        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
363            // Tracks is best-effort — never break a settings save.
364        }
365    }
366
367    /**
368     * How many required settings currently hold a value.
369     */
370    private static function filled_setting_count(): int {
371        $filled = 0;
372
373        foreach ( self::SETUP_OPTIONS as $option ) {
374            if ( self::has_value( get_option( $option, '' ) ) ) {
375                ++$filled;
376            }
377        }
378
379        return $filled;
380    }
381
382    /**
383     * Whether a required setting counts as filled in. Options read back as
384     * strings, so one rule covers both shapes: blank for the text fields, `'0'`
385     * for `podcasting_category_id`, where 0 is the disabled sentinel.
386     *
387     * @param mixed $value Value to test.
388     */
389    private static function has_value( $value ): bool {
390        $value = is_scalar( $value ) ? trim( (string) $value ) : '';
391
392        return '' !== $value && '0' !== $value;
393    }
394
395    /**
396     * Label writes made by the dashboard's own REST routes. Derived from the
397     * route rather than set by the endpoints, so nothing outside this class
398     * sits in a save path holding a reference to it.
399     *
400     * @param mixed $response Passed through untouched.
401     * @param array $handler  Unused.
402     * @param mixed $request  Request being dispatched.
403     * @return mixed
404     */
405    public static function set_surface_from_route( $response, $handler, $request ) {
406        unset( $handler );
407
408        if ( ! $request instanceof WP_REST_Request ) {
409            return $response;
410        }
411
412        $route = (string) $request->get_route();
413
414        if ( 0 === strpos( $route, '/wpcom/v2/podcast/settings' ) ) {
415            self::$surface = 'settings_rest';
416        } elseif ( 0 === strpos( $route, '/wpcom/v2/podcast-distribution/' ) ) {
417            self::$surface = 'distribution_rest';
418        }
419
420        return $response;
421    }
422
423    /**
424     * Reset once the route's callback is done, so a nested subrequest doesn't
425     * leave its surface set for the rest of the request.
426     *
427     * @param mixed $response Passed through untouched.
428     * @return mixed
429     */
430    public static function clear_surface( $response ) {
431        self::$surface = 'programmatic';
432
433        return $response;
434    }
435
436    /**
437     * Host the event came from. Plan slug is a lossy proxy — Atomic reports a
438     * WordPress.com plan server-side and a Jetpack one client-side.
439     */
440    private static function platform(): string {
441        $host = new Host();
442
443        if ( $host->is_wpcom_simple() ) {
444            return 'simple';
445        }
446
447        return $host->is_woa_site() ? 'atomic' : 'self_hosted';
448    }
449
450    /**
451     * Current plan slug. `WPCOM_Store_API` on Simple, `Current_Plan` on Atomic
452     * and self-hosted — same dual pattern as
453     * `Masterbar\Dashboard_Switcher_Tracking::get_plan()`.
454     */
455    private static function current_product_slug(): string {
456        $plan = class_exists( '\WPCOM_Store_API' )
457            ? \WPCOM_Store_API::get_current_plan( (int) get_current_blog_id() )
458            : ( class_exists( '\Automattic\Jetpack\Current_Plan' ) ? \Automattic\Jetpack\Current_Plan::get() : array() );
459
460        return (string) ( $plan['product_slug'] ?? '' );
461    }
462
463    /**
464     * Emit `wpcom_podcasting_status_changed` (enabled / disabled / changed)
465     * when the `podcasting_category_id` option transitions.
466     *
467     * @param int $old_value Previous category ID (0 == disabled).
468     * @param int $new_value New category ID (0 == disabled).
469     */
470    private static function maybe_record_status_change( int $old_value, int $new_value ): void {
471        if ( $old_value === $new_value ) {
472            return;
473        }
474
475        if ( 0 === $old_value && 0 !== $new_value ) {
476            $status = 'enabled';
477        } elseif ( 0 !== $old_value && 0 === $new_value ) {
478            $status = 'disabled';
479        } else {
480            $status = 'changed';
481        }
482
483        self::record_event(
484            'wpcom_podcasting_status_changed',
485            array(
486                'status'               => $status,
487                'surface'              => self::$surface,
488                'previous_category_id' => $old_value,
489                'new_category_id'      => $new_value,
490                'user_id'              => (int) get_current_user_id(),
491                'product_slug'         => self::current_product_slug(),
492            )
493        );
494
495        /** This action is documented in projects/packages/forms/src/contact-form/class-util.php */
496        do_action( 'jetpack_bump_stats_extras', 'wpcom-podcasting-status', $status );
497    }
498
499    /**
500     * Identity for the publish event. Scheduled/cron publishes have no
501     * logged-in user — fall back to the post author.
502     *
503     * @param WP_Post $post Post being published.
504     */
505    private static function identity_for_post( WP_Post $post ): WP_User {
506        if ( ! empty( $post->post_author ) ) {
507            $user = get_userdata( (int) $post->post_author );
508            if ( $user instanceof WP_User ) {
509                return $user;
510            }
511        }
512        return wp_get_current_user();
513    }
514
515    /**
516     * Filters out posts in the podcast category that aren't actually episodes.
517     * Covers all three authoring paths: the `jetpack/podcast-episode` block
518     * (the paid path — media lives in its `mediaUrl` attr, often an external or
519     * unattached URL that the two checks below miss), the `core/audio` block,
520     * and classic-editor attached audio.
521     *
522     * @param WP_Post $post Post being checked.
523     */
524    private static function has_podcast_media( WP_Post $post ): bool {
525        $attrs = Episode_Block_Tags::get_block_attrs( $post );
526        if ( ! empty( $attrs['mediaUrl'] ) ) {
527            return true;
528        }
529
530        return has_block( 'core/audio', $post )
531            || ! empty( get_attached_media( 'audio', $post->ID ) );
532    }
533
534    /**
535     * True when no other published post exists in the podcast category.
536     *
537     * @param int $category_id     Configured podcast category ID.
538     * @param int $current_post_id Post being published (excluded from the check).
539     */
540    private static function is_first_episode_for_site( int $category_id, int $current_post_id ): bool {
541        $existing = new WP_Query(
542            array(
543                'post_status'      => 'publish',
544                'post_type'        => 'post',
545                'cat'              => $category_id,
546                'post__not_in'     => array( $current_post_id ),
547                'posts_per_page'   => 1,
548                'fields'           => 'ids',
549                'no_found_rows'    => true,
550                'suppress_filters' => true,
551            )
552        );
553
554        return empty( $existing->posts );
555    }
556
557    /**
558     * Dispatch a tracks event. Auto-injects `blog_id` and defaults `$user`
559     * to the current user.
560     *
561     * @param string       $event_name Tracks event name.
562     * @param array        $properties Event properties.
563     * @param WP_User|null $user       Identity override; defaults to current user.
564     * @return mixed
565     */
566    private static function record_event( string $event_name, array $properties, ?WP_User $user = null ) {
567        try {
568            $user                   = $user ?? wp_get_current_user();
569            $properties['blog_id']  = (int) Connection_Manager::get_site_id( true );
570            $properties['platform'] = self::platform();
571
572            if ( ! function_exists( 'tracks_record_event' ) && function_exists( 'require_lib' ) ) {
573                require_lib( 'tracks/client' );
574            }
575
576            if ( function_exists( 'tracks_record_event' ) ) {
577                return tracks_record_event( $user, $event_name, $properties );
578            }
579
580            if ( class_exists( '\Automattic\Jetpack\Tracking' ) ) {
581                return ( new \Automattic\Jetpack\Tracking() )->tracks_record_event( $user, $event_name, $properties );
582            }
583        } catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
584            // Tracks is best-effort.
585        }
586
587        return null;
588    }
589}