PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
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
← All changes | jetpack_vendor/automattic/jetpack-podcast/src/class-tracks.php +615 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,615 @@
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 +}