PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-podcast / src / class-tracks.php

class-tracks.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at jetpack_vendor/automattic/jetpack-podcast/src/class-tracks.php

616 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Tracks instrumentation for Jetpack Podcast.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8 declare( strict_types = 1 );
9
10 namespace Automattic\Jetpack\Podcast;
11
12 use Automattic\Jetpack\A8c_Mc_Stats;
13 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14 use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
15 use Automattic\Jetpack\Podcast\Feed\Episode_Block_Tags;
16 use Automattic\Jetpack\Status\Host;
17 use Throwable;
18 use WP_Post;
19 use WP_Query;
20 use WP_REST_Request;
21 use 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 */
30 class 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 }
616