Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Podcast: write Jetpack Activity Log entries on podcast publish. One per-site `podcast_show_launched` on the first episode, plus a `podcast_episode_published` entry on every episode publish. Each entry carries an `extra` payload with the post ID, permalink, and raw title so WPcom downstream listeners can consume structured data without parsing translated content.
1 change: 1 addition & 0 deletions projects/packages/podcast/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"require": {
"php": ">=7.2",
"automattic/jetpack-status": "@dev",
"automattic/jetpack-sync": "@dev",
"automattic/jetpack-wp-build-polyfills": "@dev"
},
"require-dev": {
Expand Down
63 changes: 62 additions & 1 deletion projects/packages/podcast/src/class-tracks.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace Automattic\Jetpack\Podcast;

use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
use Automattic\Jetpack\Sync\Activity_Log_Event;
use Throwable;
use WP_Post;
use WP_Query;
Expand Down Expand Up @@ -109,6 +110,8 @@ public static function record_episode_published( $post_id, $post, $update, $post
self::identity_for_post( $post )
);

self::record_episode_published_activity( $post );

// Atomic INSERT — only one concurrent caller per site wins, so
// `show_launched` fires exactly once per site.
if ( $is_first && add_option( 'podcast_show_launched_tracked', time(), '', false ) ) {
Expand All @@ -117,6 +120,8 @@ public static function record_episode_published( $post_id, $post, $update, $post
array( 'post_id' => (int) $post->ID ),
self::identity_for_post( $post )
);

self::record_show_launched_activity( $post );
}
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort — never break a publish.
Expand Down Expand Up @@ -349,6 +354,63 @@ private static function maybe_record_status_change( int $old_value, int $new_val
do_action( 'jetpack_bump_stats_extras', 'wpcom-podcasting-status', $status );
}

/**
* Write a `podcast_show_launched` entry to the Jetpack Activity Log. The
* entry syncs to WPcom as a `jp_act_log_event` post, which downstream
* listeners (e.g. the `#podcast-alerts` Slack notifier in the wpcom
* podcasting mu-plugin) hook to surface the launch.
*
* @param WP_Post $post First episode that triggered the launch.
*/
private static function record_show_launched_activity( WP_Post $post ): void {
Activity_Log_Event::create(
array(
'title' => __( 'Podcast show launched', 'jetpack-podcast' ),
'content' => sprintf(
/* translators: 1: episode post ID, 2: episode title. */
__( 'First episode published (post %1$d): %2$s', 'jetpack-podcast' ),
(int) $post->ID,
$post->post_title
),
'source' => 'podcast_show_launched',
'severity' => 'success',
'extra' => array(
'post_id' => (int) $post->ID,
'post_url' => (string) get_permalink( $post ),
'post_title' => (string) $post->post_title,
),
)
);
}

/**
* Write a `podcast_episode_published` entry to the Jetpack Activity Log.
* Fires for every podcast episode publish (after all the same gates as
* the `wpcom_podcast_episode_published` tracks event).
*
* @param WP_Post $post Episode that triggered the event.
*/
private static function record_episode_published_activity( WP_Post $post ): void {
Activity_Log_Event::create(
array(
'title' => __( 'Podcast episode published', 'jetpack-podcast' ),
'content' => sprintf(
/* translators: 1: episode post ID, 2: episode title. */
__( 'Episode published (post %1$d): %2$s', 'jetpack-podcast' ),
(int) $post->ID,
$post->post_title
),
'source' => 'podcast_episode_published',
'severity' => 'info',
'extra' => array(
'post_id' => (int) $post->ID,
'post_url' => (string) get_permalink( $post ),
'post_title' => (string) $post->post_title,
),
)
);
}

/**
* Identity for the publish event. Scheduled/cron publishes have no
* logged-in user — fall back to the post author.
Expand Down Expand Up @@ -423,7 +485,6 @@ private static function record_event( string $event_name, array $properties, ?WP
}

if ( class_exists( '\Automattic\Jetpack\Tracking' ) ) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding automattic/jetpack-sync as a podcast package dep transitively pulls in automattic/jetpack-connection (Sync requires it), so \Automattic\Jetpack\Tracking resolves for Phan from this package now. Restoring the @phan-suppress-next-line PhanUndeclaredClassMethod would itself be flagged as an unused suppression. Leaving it removed is correct in the new dep state.

// @phan-suppress-next-line PhanUndeclaredClassMethod -- Provided by the connection package on Atomic; not a hard dep.
return ( new \Automattic\Jetpack\Tracking() )->tracks_record_event( $user, $event_name, $properties );
}
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
Expand Down
104 changes: 103 additions & 1 deletion projects/packages/podcast/tests/php/Tracks_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
namespace Automattic\Jetpack\Podcast\Tests;

use Automattic\Jetpack\Podcast\Tracks;
use Automattic\Jetpack\Sync\Activity_Log_Event;
use PHPUnit\Framework\Attributes\CoversClass;
use WorDBless\BaseTestCase;
use WorDBless\Posts as WorDBless_Posts;
use WorDBless\Users as WorDBless_Users;
use WP_Post;
use WP_REST_Request;
use WP_REST_Response;
use WP_Term;
Expand All @@ -32,7 +34,21 @@
register_taxonomy( 'category', 'post', array( 'hierarchical' => true ) );
}

$GLOBALS['jetpack_podcast_test_captured_events'] = array();
if ( ! post_type_exists( Activity_Log_Event::POST_TYPE ) ) {
Activity_Log_Event::register_post_type();
}

$GLOBALS['jetpack_podcast_test_captured_events'] = array();

Check warning on line 41 in projects/packages/podcast/tests/php/Tracks_Test.php

View workflow job for this annotation

GitHub Actions / PHP Code Sniffer (non-excluded files only)

Equals sign not aligned with surrounding assignments; expected 7 spaces but found 11 spaces (Generic.Formatting.MultipleStatementAlignment.NotSameWarning)
$GLOBALS['jetpack_podcast_test_activity_log_post_ids'] = array();

Check warning on line 42 in projects/packages/podcast/tests/php/Tracks_Test.php

View workflow job for this annotation

GitHub Actions / PHP Code Sniffer (non-excluded files only)

Equals sign not aligned with surrounding assignments; expected 1 space but found 5 spaces (Generic.Formatting.MultipleStatementAlignment.NotSameWarning)

// WorDBless does not intercept complex WP_Query SQL, so track
// jp_act_log_event inserts via this hook and verify via get_post().
add_action(
'save_post_' . Activity_Log_Event::POST_TYPE,
static function ( int $post_id ): void {
$GLOBALS['jetpack_podcast_test_activity_log_post_ids'][] = $post_id;
}
);
}

protected function tearDown(): void {
Expand All @@ -44,10 +60,12 @@
delete_option( 'podcasting_email' );
delete_option( 'podcasting_talent_name' );
delete_option( 'podcast_show_launched_tracked' );
remove_all_actions( 'save_post_' . Activity_Log_Event::POST_TYPE );
wp_cache_flush();
WorDBless_Posts::init()->clear_all_posts();
WorDBless_Users::init()->clear_all_users();
unset( $GLOBALS['jetpack_podcast_test_captured_events'] );
unset( $GLOBALS['jetpack_podcast_test_activity_log_post_ids'] );
parent::tearDown();
}

Expand All @@ -62,6 +80,47 @@
);
}

/**
* Decodes the JSON payload from an Activity Log event post.
*
* WorDBless stores post_content in its slashed form (captured at the
* wp_insert_post_data filter stage), so json_decode() needs a wp_unslash()
* fallback — the same pattern used in Activity_Log_Event::decode_payload().
*
* @param WP_Post $post Activity Log event post.
* @return array|null Decoded payload or null if decoding fails.
*/
private function decode_activity_log_payload( WP_Post $post ): ?array {
$payload = json_decode( $post->post_content, true );
if ( ! is_array( $payload ) ) {
$payload = json_decode( wp_unslash( $post->post_content ), true );
}
return is_array( $payload ) ? $payload : null;
}

/**
* Returns all published `jp_act_log_event` posts whose decoded JSON payload
* contains the given `source` value.
*
* WorDBless does not intercept complex WP_Query SQL, so posts are tracked
* via the `save_post_jp_act_log_event` hook registered in setUp() and
* retrieved individually via get_post().
*/
private function activity_log_posts_with_source( string $source ): array {
return array_values(
array_filter(
array_map( 'get_post', $GLOBALS['jetpack_podcast_test_activity_log_post_ids'] ?? array() ),
function ( ?WP_Post $post ) use ( $source ): bool {
if ( null === $post ) {
return false;
}
$payload = $this->decode_activity_log_payload( $post );
return is_array( $payload ) && isset( $payload['source'] ) && $source === $payload['source'];
}
)
);
}

/**
* WorDBless lacks term-taxonomy plumbing, so seed the `terms` object cache
* with a fully-formed `WP_Term` directly — `get_term()` short-circuits
Expand Down Expand Up @@ -333,4 +392,47 @@

$this->assertEmpty( $this->events_named( 'wpcom_podcasting_settings_saved' ) );
}

public function test_episode_published_creates_episode_published_activity_log_entry() {
$cat_id = $this->configure_podcast_category();
$post = $this->insert_post_in_category( $cat_id );

Tracks::record_episode_published( $post->ID, $post, false, null );

$log_posts = $this->activity_log_posts_with_source( 'podcast_episode_published' );
$this->assertCount( 1, $log_posts );

$payload = $this->decode_activity_log_payload( $log_posts[0] );
$this->assertSame( 'info', $payload['severity'] );
$this->assertStringContainsString( (string) $post->ID, $payload['content'] );
$this->assertStringContainsString( $post->post_title, $payload['content'] );
}

public function test_episode_published_creates_show_launched_activity_log_on_first_episode() {
$cat_id = $this->configure_podcast_category();
$post = $this->insert_post_in_category( $cat_id );

Tracks::record_episode_published( $post->ID, $post, false, null );

$log_posts = $this->activity_log_posts_with_source( 'podcast_show_launched' );
$this->assertCount( 1, $log_posts );

$payload = $this->decode_activity_log_payload( $log_posts[0] );
$this->assertSame( 'success', $payload['severity'] );
$this->assertStringContainsString( (string) $post->ID, $payload['content'] );
$this->assertStringContainsString( $post->post_title, $payload['content'] );
}

public function test_show_launched_activity_log_fires_only_once_per_site() {
$cat_id = $this->configure_podcast_category();

$first = $this->insert_post_in_category( $cat_id );
Tracks::record_episode_published( $first->ID, $first, false, null );

$second = $this->insert_post_in_category( $cat_id );
Tracks::record_episode_published( $second->ID, $second, false, null );

$this->assertCount( 1, $this->activity_log_posts_with_source( 'podcast_show_launched' ) );
$this->assertCount( 2, $this->activity_log_posts_with_source( 'podcast_episode_published' ) );
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Sync: Activity_Log_Event::create() accepts an optional `extra` array of scalar key/value pairs, persisted alongside the human-readable content so downstream consumers can read structured payload without parsing translated strings.
78 changes: 78 additions & 0 deletions projects/packages/sync/src/class-activity-log-event.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ class Activity_Log_Event {
*/
const MAX_SOURCE_LENGTH = 100;

/**
* Maximum number of entries in the `extra` payload.
*/
const MAX_EXTRA_ENTRIES = 20;

/**
* Maximum length of an `extra` key (in characters).
*/
const MAX_EXTRA_KEY_LENGTH = 64;

/**
* Maximum length of an `extra` string value (in characters). Numeric and boolean
* values pass through unchanged.
*/
const MAX_EXTRA_VALUE_LENGTH = 2000;

/**
* Whether Activity Log custom event hooks have been initialized.
*
Expand Down Expand Up @@ -143,6 +159,11 @@ public static function register_post_type() {
* @type string $content Required. Plain-text body, truncated to 5000 chars.
* @type string $source Optional. Identifier for the source of the event, e.g. 'mc'.
* @type string $severity Optional. 'info', 'success', 'warning', or 'error'. Defaults to 'info'.
* @type array $extra Optional. Flat map of string keys to scalar values
* (string, int, float, bool). Used by downstream
* consumers to carry structured payload alongside
* the human-readable content. Limited to 20 entries,
* 64-char keys, and 2000-char string values.
* }
* @return int|false Post ID on success, false if validation fails.
*/
Expand Down Expand Up @@ -412,9 +433,66 @@ private static function build_payload( array $args ) {
}
}

if ( isset( $args['extra'] ) ) {
$extra = self::sanitize_extra( $args['extra'] );
if ( ! empty( $extra ) ) {
$payload['extra'] = $extra;
}
}

return $payload;
}

/**
* Sanitizes the `extra` sub-payload: flat map of string keys to scalar values.
* Drops non-scalar values, non-string keys, and entries past the cap.
*
* @param mixed $extra Raw extra payload.
* @return array Sanitized extra payload (may be empty).
*/
private static function sanitize_extra( $extra ) {
if ( ! is_array( $extra ) ) {
return array();
}

$sanitized = array();
$count = 0;

foreach ( $extra as $key => $value ) {
if ( $count >= self::MAX_EXTRA_ENTRIES ) {
break;
}

if ( ! is_string( $key ) ) {
continue;
}

$clean_key = self::sanitize_string( $key, self::MAX_EXTRA_KEY_LENGTH );
if ( '' === $clean_key ) {
continue;
}

if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) {
$sanitized[ $clean_key ] = $value;
++$count;
continue;
}

if ( is_string( $value ) ) {
$sanitized[ $clean_key ] = self::sanitize_string( $value, self::MAX_EXTRA_VALUE_LENGTH );
++$count;
continue;
}

if ( null === $value ) {
$sanitized[ $clean_key ] = '';
++$count;
}
}

return $sanitized;
}

/**
* Builds an Activity Log event payload from post content.
*
Expand Down
Loading
Loading