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