Commit fadb4bf39e1 for woocommerce
commit fadb4bf39e1aab7e31fb6a74c749c218b8401c4f
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date: Mon Oct 12 18:16:00 2026 +0100
Add endpoints for reading the push notification step log (#68750)
* Add endpoints for reading the push notification step log
Adds three read-only routes under wc-push-notifications/step-logs that WPCOM calls to fetch the
journey of one notification, the attempts made against one device, and the attempts made against
every device a user owns, each within a time range. Files are opened by day name rather than by
listing the log directory, and stores on the database log handler get a bounded scan of the log
table with covered_from saying how far back it reached. Every response carries the store's logging
state so a screen with no rows can say why.
* Fix step log paging and double-counted failures
A reader stops once it holds a page, so when one source filled the page
exactly, the response had no next_to and older lines could not be read.
The readers now say when they stopped with lines left unread, and the
response carries next_to in that case. A failure logged as both an error
and a step line now counts once.
Also keep the file reader tests out of the real log folder, make the token
order test deterministic, and tidy the controller's imports and docblocks.
* Fix step log table reads splitting a second across windows
The table reader stopped as soon as it held a page, even when the page's
last second carried on into the next window of IDs. Those rows were then
skipped, since the next page starts a second earlier. It now reads on
until a full page is newer than the window's oldest row.
---------
Co-authored-by: Hicham Boushaba <hicham.boushaba@gmail.com>
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/StepLogRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/StepLogRestController.php
new file mode 100644
index 00000000000..b0643555632
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/StepLogRestController.php
@@ -0,0 +1,341 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Controllers;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StepLogQuery;
+use Automattic\WooCommerce\Internal\PushNotifications\Traits\AuthorizesPushNotificationRequests;
+use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * Read-only REST endpoints through which Mission Control reads the push
+ * notification step log: everything recorded on the store, the journey of one
+ * notification, and the attempts made against one device or one user's devices.
+ *
+ * Only WPCOM can call these. Every response carries the store's logging state
+ * and the earliest time the read covered, so a screen with no rows can say why.
+ *
+ * @since 11.3.0
+ */
+class StepLogRestController extends RestApiControllerBase {
+ use AuthorizesPushNotificationRequests;
+
+ /**
+ * How far back a request reads when it gives no `from`.
+ */
+ const DEFAULT_RANGE_SECONDS = 2 * DAY_IN_SECONDS;
+
+ /**
+ * The longest range a request may ask for.
+ *
+ * The file reader opens one path per day per rotation per source, so an
+ * unbounded range is a stat call per day of however long the caller asked
+ * for. Set past the 30 day default log retention, since nothing older than
+ * that survives to be read anyway.
+ */
+ const MAX_RANGE_SECONDS = 90 * DAY_IN_SECONDS;
+
+ /**
+ * The root namespace for the JSON REST API endpoints.
+ *
+ * @var string
+ */
+ protected string $route_namespace = 'wc-push-notifications';
+
+ /**
+ * The REST base for the endpoints URL.
+ *
+ * @var string
+ */
+ protected string $rest_base = 'step-logs';
+
+ /**
+ * Rows returned when the caller asks for no particular number.
+ */
+ const DEFAULT_LIMIT = 100;
+
+ /**
+ * Most rows a caller may ask for in one page.
+ */
+ const MAX_LIMIT = 500;
+
+ /**
+ * The step log query service.
+ *
+ * @var StepLogQuery
+ */
+ private StepLogQuery $query;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param StepLogQuery $query The step log query service.
+ *
+ * @since 11.3.0
+ */
+ final public function init( StepLogQuery $query ): void {
+ $this->query = $query;
+ }
+
+ /**
+ * Class identifier used by `woocommerce_rest_api_get_rest_namespaces`.
+ *
+ * Distinct from the URL namespace, because the filter keys one class per
+ * value and sibling controllers share the URL namespace.
+ *
+ * @return string
+ *
+ * @since 11.3.0
+ */
+ protected function get_rest_api_namespace(): string {
+ return 'wc-push-notifications-step-logs';
+ }
+
+ /**
+ * Registers the routes.
+ *
+ * @return void
+ *
+ * @since 11.3.0
+ */
+ public function register_routes(): void {
+ register_rest_route(
+ $this->route_namespace,
+ $this->rest_base . '/site',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_site' ),
+ 'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+ 'args' => $this->get_range_args(),
+ ),
+ )
+ );
+
+ register_rest_route(
+ $this->route_namespace,
+ $this->rest_base . '/notifications/(?P<type>[a-z_]+)/(?P<resource_id>\d+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_notification' ),
+ 'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+ 'args' => array_merge(
+ array(
+ 'type' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'enum' => array_keys( Notification::NOTIFICATION_CLASSES ),
+ 'sanitize_callback' => 'sanitize_key',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'resource_id' => array(
+ 'type' => 'integer',
+ 'required' => true,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ ),
+ $this->get_range_args()
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ $this->route_namespace,
+ $this->rest_base . '/tokens/(?P<token_id>\d+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_token' ),
+ 'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+ 'args' => array_merge(
+ array(
+ 'token_id' => array(
+ 'type' => 'integer',
+ 'required' => true,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ ),
+ $this->get_range_args()
+ ),
+ ),
+ )
+ );
+
+ register_rest_route(
+ $this->route_namespace,
+ $this->rest_base . '/users/(?P<user_id>\d+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => fn ( WP_REST_Request $request ) => $this->run( $request, 'get_user' ),
+ 'permission_callback' => array( $this, 'authorize_as_from_wpcom' ),
+ 'args' => array_merge(
+ array(
+ 'user_id' => array(
+ 'type' => 'integer',
+ 'required' => true,
+ 'minimum' => 1,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ ),
+ $this->get_range_args()
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Handles the notification journey request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response
+ *
+ * @since 11.3.0
+ */
+ protected function get_notification( WP_REST_Request $request ): WP_REST_Response {
+ list( $from, $to ) = $this->get_range( $request );
+
+ return new WP_REST_Response(
+ $this->query->for_notification(
+ (string) $request->get_param( 'type' ),
+ (int) $request->get_param( 'resource_id' ),
+ $from,
+ $to,
+ $this->get_limit( $request )
+ )
+ );
+ }
+
+ /**
+ * Handles the whole-store request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response
+ *
+ * @since 11.3.0
+ */
+ protected function get_site( WP_REST_Request $request ): WP_REST_Response {
+ list( $from, $to ) = $this->get_range( $request );
+
+ return new WP_REST_Response( $this->query->for_site( $from, $to, $this->get_limit( $request ) ) );
+ }
+
+ /**
+ * Handles the device request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response
+ *
+ * @since 11.3.0
+ */
+ protected function get_token( WP_REST_Request $request ): WP_REST_Response {
+ list( $from, $to ) = $this->get_range( $request );
+
+ return new WP_REST_Response( $this->query->for_token( (int) $request->get_param( 'token_id' ), $from, $to, $this->get_limit( $request ) ) );
+ }
+
+ /**
+ * Handles the user request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response
+ *
+ * @since 11.3.0
+ */
+ protected function get_user( WP_REST_Request $request ): WP_REST_Response {
+ list( $from, $to ) = $this->get_range( $request );
+
+ return new WP_REST_Response( $this->query->for_user( (int) $request->get_param( 'user_id' ), $from, $to, $this->get_limit( $request ) ) );
+ }
+
+ /**
+ * The `from` and `to` arguments every route accepts, as Unix timestamps.
+ *
+ * @return array
+ */
+ private function get_range_args(): array {
+ return array(
+ 'limit' => array(
+ 'description' => __( 'Rows to return, newest first. A page carries every row of its last second, so it can exceed this.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'default' => self::DEFAULT_LIMIT,
+ 'minimum' => 1,
+ 'maximum' => self::MAX_LIMIT,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'from' => array(
+ 'description' => __( 'Earliest Unix timestamp to include. Defaults to 48 hours before `to`, and is clamped to 90 days before it.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ 'to' => array(
+ 'description' => __( 'Latest Unix timestamp to include. Defaults to now.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 0,
+ 'sanitize_callback' => 'absint',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ),
+ );
+ }
+
+ /**
+ * The page size the request asked for, or the default.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return int
+ */
+ private function get_limit( WP_REST_Request $request ): int {
+ $limit = (int) $request->get_param( 'limit' );
+
+ return $limit > 0 ? $limit : self::DEFAULT_LIMIT;
+ }
+
+ /**
+ * Resolves the requested time range, applying the defaults.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return array{0: int, 1: int}
+ */
+ private function get_range( WP_REST_Request $request ): array {
+ $to = (int) $request->get_param( 'to' );
+ $from = (int) $request->get_param( 'from' );
+
+ if ( $to <= 0 ) {
+ $to = time();
+ }
+
+ if ( $from <= 0 ) {
+ $from = $to - self::DEFAULT_RANGE_SECONDS;
+ }
+
+ $from = min( $from, $to );
+
+ return array( max( $from, $to - self::MAX_RANGE_SECONDS ), $to );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
index 14a2de8287c..1499c8948b8 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/PushTokensDataStore.php
@@ -471,6 +471,36 @@ class PushTokensDataStore {
return $this->token_count;
}
+ /**
+ * Returns the IDs of the push tokens a user owns, most recently registered
+ * first.
+ *
+ * Capped, because the step log read tests every line against every ID
+ * returned. Re-registering a device updates its post, so ordering by
+ * modification time keeps the devices still in use and drops the ones
+ * abandoned longest ago.
+ *
+ * @since 11.3.0
+ * @param int $user_id The user.
+ * @param int $limit The most IDs to return.
+ * @return int[]
+ */
+ public function get_token_ids_for_user( int $user_id, int $limit = 100 ): array {
+ global $wpdb;
+
+ return array_map(
+ 'intval',
+ $wpdb->get_col(
+ $wpdb->prepare(
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s AND post_status = 'private' AND post_author = %d ORDER BY post_modified DESC LIMIT %d",
+ PushToken::POST_TYPE,
+ $user_id,
+ $limit
+ )
+ )
+ );
+ }
+
/**
* Returns push tokens belonging to users with the given roles.
*
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogFileReader.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogFileReader.php
new file mode 100644
index 00000000000..d8773914649
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogFileReader.php
@@ -0,0 +1,242 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Admin\Logging\FileV2\File;
+use Automattic\WooCommerce\Internal\Admin\Logging\Settings;
+use WC_Log_Levels;
+
+/**
+ * Reads push notification step log lines back from the WooCommerce log files.
+ *
+ * Files are addressed by day, never by listing the directory: the file handler
+ * names each file from its source and UTC date plus a hash of that name, so
+ * the path for any day can be built without a glob. Each file is streamed one
+ * line at a time, so memory is one line plus the matches whatever the file
+ * size.
+ *
+ * @since 11.3.0
+ */
+class StepLogFileReader {
+ /**
+ * The file handler keeps this many rotations of one day's file before
+ * overwriting the oldest, so this is the highest rotation suffix to probe.
+ */
+ const MAX_ROTATIONS = 10;
+
+ /**
+ * Returns the newest lines written under a source between two times.
+ *
+ * Reads one UTC day at a time, newest first, and stops once it holds enough
+ * rows, so a short page over a long range does not open every day's files.
+ * Rows are turned into what the caller wants as each line is read, and the
+ * set is trimmed after every file, so neither the range nor one large file
+ * decides how much is held at once. `has_more` says whether that left older
+ * lines unread.
+ *
+ * @param string $source The log source, e.g. `push-notifications-store-order`.
+ * @param int $from Earliest timestamp to include, inclusive.
+ * @param int $to Latest timestamp to include, inclusive.
+ * @param int $limit The fewest rows to collect before stopping.
+ * @param callable|null $expand Called with each row, returning a row per result it should produce.
+ * @return array{rows: array<int, array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}>, covered_from: int, has_more: bool}
+ *
+ * @since 11.3.0
+ */
+ public function read( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ): array {
+ $directory = Settings::get_log_directory( false );
+ $days = self::get_days( $from, $to );
+ $rows = array();
+ $covered_from = $to;
+ $has_more = false;
+
+ foreach ( $days as $index => $day ) {
+ $covered_from = max( $from, $day );
+
+ foreach ( self::get_filenames( $source, $day ) as $filename ) {
+ $file = new File( $directory . $filename );
+
+ if ( ! $file->is_readable() ) {
+ continue;
+ }
+
+ $stream = $file->get_stream();
+
+ if ( ! is_resource( $stream ) ) {
+ continue;
+ }
+
+ while ( false !== ( $line = fgets( $stream ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
+ $row = self::parse_line( $line );
+
+ if ( null === $row || $row['timestamp'] < $from || $row['timestamp'] > $to ) {
+ continue;
+ }
+
+ if ( null === $expand ) {
+ $rows[] = $row;
+ continue;
+ }
+
+ foreach ( $expand( $row ) as $expanded ) {
+ $rows[] = $expanded;
+ }
+ }
+
+ $file->close_stream();
+
+ $kept = self::keep_newest( $rows, $limit );
+ $has_more = $has_more || count( $kept ) < count( $rows );
+ $rows = $kept;
+ }
+
+ /*
+ * Every remaining day is older than everything held, so stopping at
+ * a day boundary cannot skip a row that belonged on this page. The
+ * boundary is also a second boundary, which the page's whole-second
+ * rule needs.
+ */
+ if ( count( $rows ) >= $limit ) {
+ $has_more = $has_more || isset( $days[ $index + 1 ] );
+ break;
+ }
+ }
+
+ usort( $rows, fn( array $a, array $b ) => $a['timestamp'] <=> $b['timestamp'] );
+
+ return array(
+ 'rows' => $rows,
+ 'covered_from' => $covered_from,
+ 'has_more' => $has_more,
+ );
+ }
+
+ /**
+ * The UTC days a range covers, newest first.
+ *
+ * @param int $from Earliest timestamp.
+ * @param int $to Latest timestamp.
+ * @return int[] The start of each day.
+ */
+ private static function get_days( int $from, int $to ): array {
+ $days = array();
+ $day = (int) strtotime( gmdate( 'Y-m-d', $from ) . 'T00:00:00+00:00' );
+ $until = (int) strtotime( gmdate( 'Y-m-d', $to ) . 'T00:00:00+00:00' );
+
+ while ( $day <= $until ) {
+ $days[] = $day;
+ $day += DAY_IN_SECONDS;
+ }
+
+ return array_reverse( $days );
+ }
+
+ /**
+ * Keeps the newest rows and whatever shares the oldest kept second, so a
+ * trim can never cut a second in half.
+ *
+ * @param array $rows The rows held so far.
+ * @param int $limit The fewest rows to keep.
+ * @return array
+ */
+ private static function keep_newest( array $rows, int $limit ): array {
+ if ( PHP_INT_MAX === $limit || count( $rows ) <= $limit ) {
+ return $rows;
+ }
+
+ usort( $rows, fn( array $a, array $b ) => $b['timestamp'] <=> $a['timestamp'] );
+
+ $boundary = $rows[ $limit - 1 ]['timestamp'];
+
+ return array_values( array_filter( $rows, fn( array $row ) => $row['timestamp'] >= $boundary ) );
+ }
+
+ /**
+ * The filenames that could hold one day's lines for a source: the current
+ * file and every rotation of it.
+ *
+ * @param string $source The log source.
+ * @param int $day A timestamp within the UTC day.
+ * @return string[]
+ */
+ private static function get_filenames( string $source, int $day ): array {
+ $filenames = array();
+
+ for ( $rotation = self::MAX_ROTATIONS - 1; $rotation >= 0; $rotation-- ) {
+ $filenames[] = self::build_filename( $source, $rotation, $day );
+ }
+
+ $filenames[] = self::build_filename( $source, null, $day );
+
+ return $filenames;
+ }
+
+ /**
+ * Builds a log filename the way the file handler does when writing.
+ *
+ * @param string $source The log source.
+ * @param int|null $rotation The rotation suffix, or null for the current file.
+ * @param int $day A timestamp within the UTC day.
+ * @return string
+ */
+ private static function build_filename( string $source, ?int $rotation, int $day ): string {
+ $file_id = File::generate_file_id( $source, $rotation, $day );
+
+ return $file_id . '-' . File::generate_hash( $file_id ) . '.log';
+ }
+
+ /**
+ * Parses one line as the file handler wrote it: a timestamp, a level, the
+ * message, and an optional ` CONTEXT: ` JSON block.
+ *
+ * A line whose context does not decode is still returned, with the raw
+ * text in `raw` and no context, so a plugin reshaping the line through
+ * `woocommerce_format_log_entry` degrades the row rather than the request.
+ *
+ * @param string $line The line.
+ * @return array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}|null Null for a line with no timestamp and level.
+ *
+ * @since 11.3.0
+ */
+ public static function parse_line( string $line ): ?array {
+ $line = rtrim( $line, "\r\n" );
+ $segments = explode( ' ', $line, 3 );
+
+ if ( count( $segments ) < 3 ) {
+ return null;
+ }
+
+ $timestamp = strtotime( $segments[0] );
+ $level = strtolower( $segments[1] );
+
+ if ( false === $timestamp || ! WC_Log_Levels::is_valid_level( $level ) ) {
+ return null;
+ }
+
+ $chunks = explode( ' CONTEXT: ', $segments[2], 2 );
+ $context = null;
+ $raw = null;
+
+ if ( isset( $chunks[1] ) ) {
+ $decoded = json_decode( $chunks[1], true );
+
+ if ( is_array( $decoded ) ) {
+ $context = $decoded;
+ } else {
+ $raw = $line;
+ }
+ }
+
+ return array(
+ 'timestamp' => $timestamp,
+ 'level' => $level,
+ 'message' => $chunks[0],
+ 'context' => $context,
+ 'raw' => $raw,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogTableReader.php b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogTableReader.php
new file mode 100644
index 00000000000..3a5eb7ecffa
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/DataStores/StepLogTableReader.php
@@ -0,0 +1,183 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\DataStores;
+
+defined( 'ABSPATH' ) || exit;
+
+use WC_Log_Levels;
+
+/**
+ * Reads push notification step log lines back from the `wc_log` table, for
+ * stores that chose the database log handler.
+ *
+ * The table indexes only `log_id` and `level`, so a query by source would scan
+ * every row. Instead the reader walks back from the newest row in windows of
+ * primary key IDs, which bounds the rows examined whatever the table size, and
+ * stops once it has passed the start of the requested range or examined its
+ * limit. Coverage can therefore fall short of the range on a store whose other
+ * plugins log heavily; `covered_from` says where the read stopped.
+ *
+ * @since 11.3.0
+ */
+class StepLogTableReader {
+ /**
+ * Rows examined per window.
+ */
+ const WINDOW_SIZE = 5000;
+
+ /**
+ * Most rows examined in one read.
+ */
+ const MAX_ROWS_EXAMINED = 50000;
+
+ /**
+ * Returns every row written under a source between two times, as far back
+ * as the bounded scan reached.
+ *
+ * Rows are turned into what the caller wants as each one is read, and the
+ * scan stops once it holds enough, so a short page does not walk the whole
+ * row budget. `has_more` says whether it stopped with older windows unread.
+ *
+ * @param string $source The log source, e.g. `push-notifications-store-order`.
+ * @param int $from Earliest timestamp to include, inclusive.
+ * @param int $to Latest timestamp to include, inclusive.
+ * @param int $limit The fewest rows to collect before stopping.
+ * @param callable|null $expand Called with each row, returning a row per result it should produce.
+ * @return array{rows: array<int, array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}>, covered_from: int, has_more: bool}
+ *
+ * @since 11.3.0
+ */
+ public function read( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ): array {
+ global $wpdb;
+
+ $table = $wpdb->prefix . 'woocommerce_log';
+ $max_id = (int) $wpdb->get_var( "SELECT MAX(log_id) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = array();
+
+ $upper = $max_id;
+ $examined = 0;
+ $covered_from = $from;
+ $has_more = false;
+
+ while ( $upper > 0 && $examined < self::MAX_ROWS_EXAMINED ) {
+ $lower = max( 0, $upper - self::WINDOW_SIZE );
+
+ $results = $wpdb->get_results(
+ $wpdb->prepare(
+ "SELECT log_id, timestamp, level, message, context FROM {$table} WHERE log_id > %d AND log_id <= %d AND source = %s ORDER BY log_id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $lower,
+ $upper,
+ $source
+ ),
+ ARRAY_A
+ );
+
+ foreach ( (array) $results as $result ) {
+ $row = self::build_row( $result );
+
+ if ( $row['timestamp'] < $from || $row['timestamp'] > $to ) {
+ continue;
+ }
+
+ if ( null === $expand ) {
+ $rows[] = $row;
+ continue;
+ }
+
+ foreach ( $expand( $row ) as $expanded ) {
+ $rows[] = $expanded;
+ }
+ }
+
+ $examined += $upper - $lower;
+ $oldest = $this->get_window_start_timestamp( $table, $lower );
+ $upper = $lower;
+
+ /*
+ * Windows are walked newest first, so everything left is older than
+ * this window's oldest row. That row's second can carry on into the
+ * next window, so stop only once a full page is newer than it.
+ */
+ if ( count( array_filter( $rows, fn( array $row ) => null === $oldest || $row['timestamp'] > $oldest ) ) >= $limit ) {
+ $covered_from = null === $oldest ? $from : $oldest;
+ $has_more = $upper > 0 && $covered_from > $from;
+ break;
+ }
+
+ if ( null === $oldest ) {
+ continue;
+ }
+
+ if ( $oldest <= $from ) {
+ $covered_from = $from;
+ break;
+ }
+
+ $covered_from = $oldest;
+ }
+
+ if ( 0 === $upper ) {
+ $covered_from = $from;
+ }
+
+ usort( $rows, fn( array $a, array $b ) => $a['timestamp'] <=> $b['timestamp'] );
+
+ return array(
+ 'rows' => $rows,
+ 'covered_from' => $covered_from,
+ 'has_more' => $has_more,
+ );
+ }
+
+ /**
+ * Returns the timestamp of the oldest row in the window just read, to
+ * decide whether the scan has passed the start of the range.
+ *
+ * @param string $table The log table name.
+ * @param int $lower The exclusive lower ID bound of the window just read.
+ * @return int|null Null when the window held no rows at all.
+ */
+ private function get_window_start_timestamp( string $table, int $lower ): ?int {
+ global $wpdb;
+
+ $timestamp = $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT timestamp FROM {$table} WHERE log_id > %d ORDER BY log_id ASC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $lower
+ )
+ );
+
+ return null === $timestamp ? null : (int) strtotime( $timestamp . ' UTC' );
+ }
+
+ /**
+ * Converts one table row to the shape the file reader produces.
+ *
+ * @param array $result The row as `wpdb` returned it.
+ * @return array{timestamp: int, level: string, message: string, context: array|null, raw: string|null}
+ */
+ private static function build_row( array $result ): array {
+ $context = null;
+ $raw = null;
+
+ if ( ! empty( $result['context'] ) ) {
+ $decoded = json_decode( (string) $result['context'], true );
+
+ if ( is_array( $decoded ) ) {
+ $context = $decoded;
+ } else {
+ $raw = (string) $result['context'];
+ }
+ }
+
+ return array(
+ 'timestamp' => (int) strtotime( $result['timestamp'] . ' UTC' ),
+ 'level' => (string) WC_Log_Levels::get_severity_level( (int) $result['level'] ),
+ 'message' => (string) $result['message'],
+ 'context' => $context,
+ 'raw' => $raw,
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 048c9bd55c5..3ca92dfb73e 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -10,6 +10,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Controllers\NotificationPr
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificationStatusRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\StepLogRestController;
use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
use Automattic\WooCommerce\Internal\PushNotifications\Services\DriverAvailabilityService;
use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
@@ -86,6 +87,11 @@ class PushNotifications {
// write routes only while the module is enabled, like everything below.
( new PushTokenRestController() )->register();
+ // Registered ahead of the enablement check for the same reason: a store
+ // is most worth reading the step log for once push notifications have
+ // been switched off, which is often why they were switched off.
+ wc_get_container()->get( StepLogRestController::class )->register();
+
if ( ! $this->should_be_enabled() ) {
return;
}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/StepLogQuery.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/StepLogQuery.php
new file mode 100644
index 00000000000..81a6b999366
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/StepLogQuery.php
@@ -0,0 +1,541 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
+use Automattic\WooCommerce\Internal\Admin\Logging\Settings;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogFileReader;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogTableReader;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Utilities\LoggingUtil;
+use WC_Log_Handler_DB;
+
+/**
+ * Answers the questions Mission Control asks of the push notification step
+ * log: what happened to one notification, and what has been sent to one
+ * token or one user.
+ *
+ * Reads from the log files or the log table depending on which handler the
+ * store uses, and returns the store's logging state with every answer so a
+ * screen with no rows can say why.
+ *
+ * @since 11.3.0
+ */
+class StepLogQuery {
+ /**
+ * The file reader.
+ *
+ * @var StepLogFileReader
+ */
+ private StepLogFileReader $file_reader;
+
+ /**
+ * The table reader.
+ *
+ * @var StepLogTableReader
+ */
+ private StepLogTableReader $table_reader;
+
+ /**
+ * The push tokens data store.
+ *
+ * @var PushTokensDataStore
+ */
+ private PushTokensDataStore $data_store;
+
+ /**
+ * The step logger, consulted for whether logging is active on this store.
+ *
+ * @var NotificationStepLogger
+ */
+ private NotificationStepLogger $step_logger;
+
+ /**
+ * Initialize injected dependencies.
+ *
+ * @internal
+ *
+ * @param StepLogFileReader $file_reader The file reader.
+ * @param StepLogTableReader $table_reader The table reader.
+ * @param PushTokensDataStore $data_store The push tokens data store.
+ * @param NotificationStepLogger $step_logger The step logger.
+ *
+ * @since 11.3.0
+ */
+ final public function init(
+ StepLogFileReader $file_reader,
+ StepLogTableReader $table_reader,
+ PushTokensDataStore $data_store,
+ NotificationStepLogger $step_logger
+ ): void {
+ $this->file_reader = $file_reader;
+ $this->table_reader = $table_reader;
+ $this->data_store = $data_store;
+ $this->step_logger = $step_logger;
+ }
+
+ /**
+ * The order the steps run in, used only to break ties inside one second.
+ *
+ * @var array<string, int>
+ */
+ private const STEP_ORDER = array(
+ 'triggered' => 1,
+ 'loopback_requested' => 2,
+ 'loopback_started' => 3,
+ 'fallback' => 4,
+ 'processing' => 5,
+ 'skipped' => 6,
+ 'recipients' => 7,
+ 'token_included' => 8,
+ 'token_excluded' => 9,
+ 'dispatched' => 10,
+ 'retry' => 11,
+ );
+
+ /**
+ * Returns every step recorded on the store.
+ *
+ * Reads every notification type's source, the store-wide suppressed source
+ * and the module's error source. No source is per token, so the number of
+ * files read does not grow with the number of devices the store holds.
+ *
+ * @param int $from Earliest timestamp, inclusive.
+ * @param int $to Latest timestamp, inclusive.
+ * @param int $limit Rows to return, newest first.
+ * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+ *
+ * @since 11.3.0
+ */
+ public function for_site( int $from, int $to, int $limit ): array {
+ $sources = array_map(
+ array( NotificationStepLogger::class, 'get_notification_source_for_type' ),
+ array_keys( Notification::NOTIFICATION_CLASSES )
+ );
+
+ $sources[] = NotificationStepLogger::SUPPRESSED_SOURCE;
+ $sources[] = PushNotifications::FEATURE_NAME;
+
+ return $this->query( $sources, $from, $to, $limit );
+ }
+
+ /**
+ * Returns every step recorded for one resource's notifications of one
+ * type, from the journey file and from the module's error log.
+ *
+ * @param string $type The notification type, e.g. `store_order`.
+ * @param int $resource_id The order, comment or product ID.
+ * @param int $from Earliest timestamp, inclusive.
+ * @param int $to Latest timestamp, inclusive.
+ * @param int $limit Rows to return, newest first.
+ * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+ *
+ * @since 11.3.0
+ */
+ public function for_notification( string $type, int $resource_id, int $from, int $to, int $limit ): array {
+ $sources = array(
+ NotificationStepLogger::get_notification_source_for_type( $type ),
+ NotificationStepLogger::SUPPRESSED_SOURCE,
+ PushNotifications::FEATURE_NAME,
+ );
+
+ return $this->query(
+ $sources,
+ $from,
+ $to,
+ $limit,
+ function ( array $row ) use ( $type, $resource_id ): array {
+ $context = $row['context'] ?? array();
+
+ /*
+ * A batch line covers many notifications, so it names each one
+ * by type and resource ID rather than carrying one of its own.
+ */
+ if ( isset( $context['notifications'] ) && is_array( $context['notifications'] ) ) {
+ foreach ( $context['notifications'] as $covered ) {
+ if ( isset( $covered['type'], $covered['resource_id'] )
+ && $type === $covered['type']
+ && $resource_id === (int) $covered['resource_id'] ) {
+ return array( $row );
+ }
+ }
+
+ return array();
+ }
+
+ return isset( $context['type'], $context['resource_id'] )
+ && $type === $context['type']
+ && $resource_id === (int) $context['resource_id']
+ ? array( $row )
+ : array();
+ }
+ );
+ }
+
+ /**
+ * Returns every step recorded against one token.
+ *
+ * No line is stored per token, so this finds the token named inside the
+ * notification-level lines and returns a row for it.
+ *
+ * @param int $token_id The push token post ID.
+ * @param int $from Earliest timestamp, inclusive.
+ * @param int $to Latest timestamp, inclusive.
+ * @param int $limit Rows to return, newest first.
+ * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+ *
+ * @since 11.3.0
+ */
+ public function for_token( int $token_id, int $from, int $to, int $limit ): array {
+ return $this->query(
+ self::get_token_sources(),
+ $from,
+ $to,
+ $limit,
+ fn( array $row ) => self::split_line_into_token_rows( $row, array( $token_id ) )
+ );
+ }
+
+ /**
+ * Returns every step recorded against every token one user owns.
+ *
+ * @param int $user_id The user.
+ * @param int $from Earliest timestamp, inclusive.
+ * @param int $to Latest timestamp, inclusive.
+ * @param int $limit Rows to return, newest first.
+ * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+ *
+ * @since 11.3.0
+ */
+ public function for_user( int $user_id, int $from, int $to, int $limit ): array {
+ $token_ids = $this->data_store->get_token_ids_for_user( $user_id );
+
+ return $this->query(
+ self::get_token_sources(),
+ $from,
+ $to,
+ $limit,
+ fn( array $row ) => self::split_line_into_token_rows( $row, $token_ids )
+ );
+ }
+
+ /**
+ * The sources that can name a token: every notification type's own source
+ * and the store-wide suppressed source.
+ *
+ * @return string[]
+ */
+ private static function get_token_sources(): array {
+ $sources = array_map(
+ array( NotificationStepLogger::class, 'get_notification_source_for_type' ),
+ array_keys( Notification::NOTIFICATION_CLASSES )
+ );
+
+ $sources[] = NotificationStepLogger::SUPPRESSED_SOURCE;
+
+ return $sources;
+ }
+
+ /**
+ * Splits one stored line into a row per token it names, so a per-token read
+ * sees the same shape it would have seen when every token had a line of its
+ * own.
+ *
+ * @param array $row The stored row.
+ * @param int[] $token_ids The tokens being asked about.
+ * @return array[] A row per token named, which may be none.
+ */
+ private static function split_line_into_token_rows( array $row, array $token_ids ): array {
+ $context = $row['context'] ?? array();
+ $rows = array();
+
+ foreach ( $token_ids as $token_id ) {
+ $token_id = (int) $token_id;
+
+ foreach ( (array) ( $context['excluded_tokens'] ?? array() ) as $reason => $excluded ) {
+ if ( in_array( $token_id, array_map( 'intval', (array) $excluded ), true ) ) {
+ $rows[] = self::strip_unrelated_tokens( $row, $token_id, 'token_excluded', (string) $reason );
+ continue 2;
+ }
+ }
+
+ if ( in_array( $token_id, array_map( 'intval', (array) ( $context['invalid_token_ids'] ?? array() ) ), true ) ) {
+ $rows[] = self::strip_unrelated_tokens( $row, $token_id, 'dispatched', 'invalid_token' );
+ continue;
+ }
+
+ if ( in_array( $token_id, array_map( 'intval', (array) ( $context['token_ids'] ?? array() ) ), true ) ) {
+ $rows[] = self::strip_unrelated_tokens( $row, $token_id, 'token_included', 'ok' );
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * Returns the row for one token, with every other token's data removed.
+ *
+ * The stored line names every token the notification touched, so the three
+ * token lists are dropped and the step and outcome are replaced with what
+ * happened to this one. Two rows built from the same line can therefore
+ * carry different outcomes.
+ *
+ * @param array $row The stored row.
+ * @param int $token_id The token to keep.
+ * @param string $step The step to report for that token.
+ * @param string $outcome The outcome to report for that token.
+ * @return array
+ */
+ private static function strip_unrelated_tokens( array $row, int $token_id, string $step, string $outcome ): array {
+ $context = $row['context'] ?? array();
+
+ unset( $context['excluded_tokens'], $context['invalid_token_ids'], $context['token_ids'] );
+
+ $context['token_id'] = $token_id;
+ $context['step'] = $step;
+ $context['outcome'] = $outcome;
+
+ $row['context'] = $context;
+ $row['message'] = ucfirst( str_replace( '_', ' ', $step ) ) . ': ' . str_replace( '_', ' ', $outcome );
+
+ return $row;
+ }
+
+ /**
+ * Returns the store's logging state: the settings that decide whether
+ * step lines are written and kept, the store's token count, and the caps a
+ * line's token lists are cut to, so a reader can tell a short list from a
+ * truncated one.
+ *
+ * @return array
+ *
+ * @since 11.3.0
+ */
+ public function get_logging_state(): array {
+ $directory = Settings::get_log_directory( false );
+
+ return array(
+ 'logging_enabled' => LoggingUtil::logging_is_enabled(),
+ 'push_logging_enabled' => $this->step_logger->is_active(),
+ 'default_handler' => LoggingUtil::get_default_handler(),
+ 'level_threshold' => LoggingUtil::get_level_threshold(),
+ 'retention_period_days' => LoggingUtil::get_retention_period(),
+ 'log_directory_writable' => is_dir( $directory ) && wp_is_writable( $directory ),
+ 'token_count' => $this->data_store->count_tokens(),
+ 'recipient_id_cap' => NotificationProcessor::RECIPIENT_ID_CAP,
+ 'max_array_items' => NotificationStepLogger::MAX_ARRAY_ITEMS,
+ );
+ }
+
+ /**
+ * Reads the sources, keeps the rows the filter accepts, and assembles the
+ * answer.
+ *
+ * @param string[] $sources The log sources to read.
+ * @param int $from Earliest timestamp, inclusive.
+ * @param int $to Latest timestamp, inclusive.
+ * @param int $limit Rows to return, before the whole-second rule.
+ * @param callable|null $expand Called with each row, returning a row per result it should produce.
+ * @return array{rows: array, counts: array<string, int>, covered_from: int, next_to: int|null, logging: array}
+ */
+ private function query( array $sources, int $from, int $to, int $limit, ?callable $expand = null ): array {
+ $rows = array();
+ $covered_from = $from;
+ $has_more = false;
+
+ foreach ( $sources as $source ) {
+ /*
+ * The readers apply the expansion as each line is read and stop
+ * once they hold enough, so neither the range nor one large file
+ * decides how much is held here.
+ */
+ $result = $this->read( $source, $from, $to, $limit, $expand );
+ $covered_from = max( $covered_from, $result['covered_from'] );
+ $has_more = $has_more || $result['has_more'];
+ $rows = array_merge( $rows, $result['rows'] );
+ }
+
+ usort(
+ $rows,
+ function ( array $a, array $b ): int {
+ if ( $a['timestamp'] !== $b['timestamp'] ) {
+ return $b['timestamp'] <=> $a['timestamp'];
+ }
+
+ return self::get_order_within_a_second( $b ) <=> self::get_order_within_a_second( $a );
+ }
+ );
+
+ list( $rows, $next_to ) = self::take_page( $rows, $limit, $has_more );
+
+ return array(
+ 'rows' => array_map( array( self::class, 'format_row' ), $rows ),
+ 'counts' => self::count_outcomes( $rows ),
+ 'covered_from' => $covered_from,
+ 'next_to' => $next_to,
+ 'logging' => $this->get_logging_state(),
+ );
+ }
+
+ /**
+ * Where a line sits among the lines sharing its second.
+ *
+ * Timestamps are whole seconds, and the request that dispatches a
+ * notification and the loopback request that processes it run at the same
+ * time in separate processes, so neither the timestamp nor the order the
+ * lines were written tells a reader which step came first. Ordering ties by
+ * the lifecycle the steps run in does.
+ *
+ * A step this does not name sorts to the end of its second.
+ *
+ * @param array $row The row.
+ * @return int
+ */
+ private static function get_order_within_a_second( array $row ): int {
+ return self::STEP_ORDER[ $row['context']['step'] ?? '' ] ?? 0;
+ }
+
+ /**
+ * Takes one page off the front of the rows, with the cursor for the next.
+ *
+ * Timestamps are whole seconds and several steps of one notification share
+ * a second, so a page that stopped mid-second would skip or repeat rows on
+ * the next read. Where the limit falls inside a second the page carries
+ * every row of that second and may exceed the limit.
+ *
+ * @param array $rows Rows, newest first.
+ * @param int $limit Rows to return, before the whole-second rule.
+ * @param bool $has_more Whether a reader stopped with older rows unread, which it only does once it holds the limit.
+ * @return array{0: list<array>, 1: int|null} The page, and the `to` for the next page or null.
+ */
+ private static function take_page( array $rows, int $limit, bool $has_more ): array {
+ if ( count( $rows ) <= $limit && ! $has_more ) {
+ return array( array_values( $rows ), null );
+ }
+
+ $rows = array_values( $rows );
+ $boundary = (int) $rows[ $limit - 1 ]['timestamp'];
+ $page = array_slice( $rows, 0, $limit );
+ $next = $limit;
+
+ while ( isset( $rows[ $next ] ) && $boundary === (int) $rows[ $next ]['timestamp'] ) {
+ $page[] = $rows[ $next ];
+ ++$next;
+ }
+
+ return array( $page, ( isset( $rows[ $next ] ) || $has_more ) ? $boundary - 1 : null );
+ }
+
+ /**
+ * Reads one source through the reader that matches the store's handler.
+ *
+ * @param string $source The log source.
+ * @param int $from Earliest timestamp, inclusive.
+ * @param int $to Latest timestamp, inclusive.
+ * @param int $limit The fewest rows the reader collects before stopping.
+ * @param callable|null $expand Called with each row, returning a row per result it should produce.
+ * @return array{rows: array, covered_from: int, has_more: bool}
+ */
+ private function read( string $source, int $from, int $to, int $limit, ?callable $expand ): array {
+ $handler = LoggingUtil::get_default_handler();
+
+ if ( is_a( $handler, WC_Log_Handler_DB::class, true ) ) {
+ return $this->table_reader->read( $source, $from, $to, $limit, $expand );
+ }
+
+ if ( is_a( $handler, LogHandlerFileV2::class, true ) ) {
+ return $this->file_reader->read( $source, $from, $to, $limit, $expand );
+ }
+
+ return array(
+ 'rows' => array(),
+ 'covered_from' => $to,
+ 'has_more' => false,
+ );
+ }
+
+ /**
+ * Counts rows per step and outcome, as `step:outcome` keys.
+ *
+ * A failure is logged twice, as an error line and as an `info` step line, so
+ * each step line cancels one error line for the same notification, step and
+ * outcome, and the failure counts once.
+ *
+ * @param array $rows The rows.
+ * @return array<string, int>
+ */
+ private static function count_outcomes( array $rows ): array {
+ $step_lines = array();
+
+ foreach ( $rows as $row ) {
+ $failure = self::get_failure_key( $row );
+
+ if ( null !== $failure && 'info' === $row['level'] ) {
+ $step_lines[ $failure ] = ( $step_lines[ $failure ] ?? 0 ) + 1;
+ }
+ }
+
+ $counts = array();
+
+ foreach ( $rows as $row ) {
+ if ( ! isset( $row['context']['step'], $row['context']['outcome'] ) ) {
+ continue;
+ }
+
+ $failure = self::get_failure_key( $row );
+
+ if ( null !== $failure && 'info' !== $row['level'] && ( $step_lines[ $failure ] ?? 0 ) > 0 ) {
+ --$step_lines[ $failure ];
+ continue;
+ }
+
+ $key = $row['context']['step'] . ':' . $row['context']['outcome'];
+ $counts[ $key ] = ( $counts[ $key ] ?? 0 ) + 1;
+ }
+
+ return $counts;
+ }
+
+ /**
+ * Names the notification, step and outcome a row records.
+ *
+ * @param array $row The row.
+ * @return string|null Null for a row that names no notification, such as a batch line.
+ */
+ private static function get_failure_key( array $row ): ?string {
+ $context = $row['context'] ?? array();
+
+ return isset( $context['identifier'], $context['step'], $context['outcome'] )
+ ? $context['identifier'] . '|' . $context['step'] . '|' . $context['outcome']
+ : null;
+ }
+
+ /**
+ * Shapes one row for the REST response.
+ *
+ * @param array $row The row as a reader produced it.
+ * @return array
+ */
+ private static function format_row( array $row ): array {
+ $formatted = array(
+ 'timestamp' => gmdate( 'c', $row['timestamp'] ),
+ 'level' => $row['level'],
+ 'message' => $row['message'],
+ 'context' => $row['context'],
+ );
+
+ // Only a line whose context did not parse has raw text to show, and for
+ // those it is all there is, since the context is null.
+ if ( null !== $row['raw'] ) {
+ $formatted['raw'] = $row['raw'];
+ }
+
+ return $formatted;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/StepLogRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/StepLogRestControllerTest.php
new file mode 100644
index 00000000000..3681f35e739
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Controllers/StepLogRestControllerTest.php
@@ -0,0 +1,239 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Controllers;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Controllers\StepLogRestController;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StepLogQuery;
+use Automattic\WooCommerce\Tests\Internal\PushNotifications\Helpers\PushNotificationsTestTrait;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the StepLogRestController class.
+ */
+class StepLogRestControllerTest extends WC_Unit_Test_Case {
+ use PushNotificationsTestTrait;
+
+ /**
+ * The System Under Test.
+ *
+ * @var StepLogRestController
+ */
+ private $sut;
+
+ /**
+ * Mock query service.
+ *
+ * @var StepLogQuery|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $query;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->query = $this->createMock( StepLogQuery::class );
+ $this->sut = new StepLogRestController();
+ $this->sut->init( $this->query );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ $this->reset_container_replacements();
+ wc_get_container()->reset_all_resolved();
+ $this->clear_rest_server();
+
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should register the four step log routes.
+ */
+ public function test_register_routes_adds_the_routes(): void {
+ $server = $this->create_rest_server_with_routes( array( array( $this->sut, 'register_routes' ) ), true );
+ $routes = array_keys( $server->get_routes( 'wc-push-notifications' ) );
+
+ $this->assertContains( '/wc-push-notifications/step-logs/site', $routes );
+ $this->assertContains( '/wc-push-notifications/step-logs/notifications/(?P<type>[a-z_]+)/(?P<resource_id>\d+)', $routes );
+ $this->assertContains( '/wc-push-notifications/step-logs/tokens/(?P<token_id>\d+)', $routes );
+ $this->assertContains( '/wc-push-notifications/step-logs/users/(?P<user_id>\d+)', $routes );
+ }
+
+ /**
+ * @testdox Should refuse a request that is not signed with the Jetpack blog token.
+ */
+ public function test_authorize_rejects_without_blog_token(): void {
+ $this->mock_jetpack_connection_manager_is_connected();
+
+ $result = $this->sut->authorize_as_from_wpcom( new WP_REST_Request( 'GET', '/wc-push-notifications/step-logs/tokens/1' ) );
+
+ $this->assertWPError( $result );
+ $this->assertSame( 'woocommerce_rest_cannot_view', $result->get_error_code() );
+ }
+
+ /**
+ * A store is most worth reading the step log for once push notifications
+ * have been switched off, which is often why they were switched off.
+ *
+ * @testdox Should still answer while push notifications are disabled.
+ */
+ public function test_authorize_allows_reading_while_disabled(): void {
+ add_filter( 'pre_option_woocommerce_feature_push_notifications_enabled', fn() => 'no' );
+
+ $server = $this->create_rest_server_with_routes( array( array( $this->sut, 'register_routes' ) ), true );
+
+ $this->assertContains( '/wc-push-notifications/step-logs/site', array_keys( $server->get_routes( 'wc-push-notifications' ) ) );
+
+ remove_filter( 'pre_option_woocommerce_feature_push_notifications_enabled', fn() => 'no' );
+ }
+
+ /**
+ * @testdox Should pass the requested range through and default it to the last 48 hours ending now.
+ */
+ public function test_routes_pass_the_range_to_the_query(): void {
+ $server = $this->create_authorized_server();
+ $answer = array(
+ 'rows' => array(),
+ 'counts' => array(),
+ 'covered_from' => 100,
+ 'logging' => array(),
+ );
+
+ $this->query->expects( $this->once() )
+ ->method( 'for_notification' )
+ ->with( 'store_order', 42, 100, 200 )
+ ->willReturn( $answer );
+ $this->query->expects( $this->once() )
+ ->method( 'for_token' )
+ ->with(
+ 4412,
+ $this->callback( fn( int $from ) => abs( $from - ( time() - StepLogRestController::DEFAULT_RANGE_SECONDS ) ) <= 2 ),
+ $this->callback( fn( int $to ) => abs( $to - time() ) <= 2 )
+ )
+ ->willReturn( $answer );
+ $this->query->expects( $this->once() )
+ ->method( 'for_user' )
+ ->with( 7, 100, 200 )
+ ->willReturn( $answer );
+ $this->query->expects( $this->once() )
+ ->method( 'for_site' )
+ ->with( 100, 200 )
+ ->willReturn( $answer );
+
+ $this->assertSame(
+ $answer,
+ $this->dispatch_as_wpcom(
+ $server,
+ '/wc-push-notifications/step-logs/notifications/store_order/42',
+ array(
+ 'from' => 100,
+ 'to' => 200,
+ )
+ )
+ );
+ $this->assertSame( $answer, $this->dispatch_as_wpcom( $server, '/wc-push-notifications/step-logs/tokens/4412' ) );
+ $this->assertSame(
+ $answer,
+ $this->dispatch_as_wpcom(
+ $server,
+ '/wc-push-notifications/step-logs/users/7',
+ array(
+ 'from' => 100,
+ 'to' => 200,
+ )
+ )
+ );
+ $this->assertSame(
+ $answer,
+ $this->dispatch_as_wpcom(
+ $server,
+ '/wc-push-notifications/step-logs/site',
+ array(
+ 'from' => 100,
+ 'to' => 200,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox Should clamp a range longer than the maximum, so one bad parameter cannot stall the request.
+ */
+ public function test_an_over_long_range_is_clamped(): void {
+ $server = $this->create_authorized_server();
+
+ $this->query->expects( $this->once() )
+ ->method( 'for_site' )
+ ->with(
+ $this->callback(
+ fn( int $from ) => abs( $from - ( time() - StepLogRestController::MAX_RANGE_SECONDS ) ) <= 2
+ ),
+ $this->anything(),
+ $this->anything()
+ )
+ ->willReturn(
+ array(
+ 'rows' => array(),
+ 'counts' => array(),
+ 'covered_from' => 0,
+ 'logging' => array(),
+ )
+ );
+
+ $this->dispatch_as_wpcom( $server, '/wc-push-notifications/step-logs/site', array( 'from' => 1 ) );
+ }
+
+ /**
+ * @testdox Should reject an unknown notification type.
+ */
+ public function test_rejects_an_unknown_type(): void {
+ $server = $this->create_authorized_server();
+
+ $response = $server->dispatch( new WP_REST_Request( 'GET', '/wc-push-notifications/step-logs/notifications/unknown_type/42' ) );
+
+ $this->assertSame( 400, $response->get_status() );
+ }
+
+ /**
+ * Dispatches a request and returns the response data.
+ *
+ * @param \WP_REST_Server $server The server.
+ * @param string $route The route.
+ * @param array $params Query parameters.
+ * @return array
+ */
+ private function dispatch_as_wpcom( \WP_REST_Server $server, string $route, array $params = array() ): array {
+ $request = new WP_REST_Request( 'GET', $route );
+ $request->set_query_params( $params );
+
+ $response = $server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status() );
+
+ return $response->get_data();
+ }
+
+ /**
+ * Creates a server whose step log routes accept every request, since a
+ * Jetpack blog token signature cannot be produced in a unit test.
+ *
+ * @return \WP_REST_Server
+ */
+ private function create_authorized_server(): \WP_REST_Server {
+ $controller = new class() extends StepLogRestController {
+ // phpcs:ignore Squiz.Commenting.FunctionComment.Missing
+ public function authorize_as_from_wpcom( WP_REST_Request $request ) {
+ return true;
+ }
+ };
+ $controller->init( $this->query );
+
+ return $this->create_rest_server_with_routes( array( array( $controller, 'register_routes' ) ), true );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
index 159617105d9..312de3e4d12 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/PushTokensDataStoreTest.php
@@ -778,6 +778,57 @@ class PushTokensDataStoreTest extends WC_Unit_Test_Case {
$this->assertCount( 1, $data_store->get_tokens_for_roles( array( 'administrator' ) ) );
}
+ /**
+ * @testdox Should return only the given user's token IDs, most recently modified first.
+ */
+ public function test_get_token_ids_for_user_returns_only_that_users_tokens(): void {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $manager_id = $this->factory->user->create( array( 'role' => 'shop_manager' ) );
+ $data_store = new PushTokensDataStore();
+ $first = $data_store->create( $this->token_data_for( $admin_id ) );
+ $data_store->create( $this->token_data_for( $manager_id ) );
+ $second = $data_store->create( $this->token_data_for( $admin_id ) );
+ $this->set_token_dates( $first->get_id(), '2026-01-01 00:00:00', '2026-01-01 00:00:00' );
+ $this->set_token_dates( $second->get_id(), '2026-01-01 00:00:00', '2026-02-01 00:00:00' );
+
+ $this->assertSame( array( $second->get_id(), $first->get_id() ), $data_store->get_token_ids_for_user( $admin_id ) );
+ $this->assertSame( array(), $data_store->get_token_ids_for_user( 999999 ) );
+ }
+
+ /**
+ * @testdox Should count every token whatever the owner's role.
+ */
+ public function test_count_tokens_counts_every_token(): void {
+ $admin_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
+ $subscriber_id = $this->factory->user->create( array( 'role' => 'subscriber' ) );
+ $data_store = new PushTokensDataStore();
+
+ $this->assertSame( 0, $data_store->count_tokens() );
+
+ $data_store->create( $this->token_data_for( $admin_id ) );
+ $data_store->create( $this->token_data_for( $subscriber_id ) );
+
+ $this->assertSame( 2, $data_store->count_tokens() );
+ }
+
+ /**
+ * Builds valid creation data for a token owned by the given user.
+ *
+ * @param int $user_id The owning user.
+ * @return array
+ */
+ private function token_data_for( int $user_id ): array {
+ return array(
+ 'user_id' => $user_id,
+ 'token' => 'token_' . wp_rand(),
+ 'platform' => PushToken::PLATFORM_APPLE,
+ 'device_uuid' => 'device-' . wp_rand(),
+ 'origin' => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+ 'device_locale' => 'en_US',
+ 'metadata' => array( 'app_version' => '1.0' ),
+ );
+ }
+
/**
* @testdox Should return tokens for users with matching roles.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogFileReaderTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogFileReaderTest.php
new file mode 100644
index 00000000000..a45617f26dd
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogFileReaderTest.php
@@ -0,0 +1,235 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\DataStores;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Admin\Logging\LogHandlerFileV2;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogFileReader;
+use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StepLogFileReader class.
+ */
+class StepLogFileReaderTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var StepLogFileReader
+ */
+ private $sut;
+
+ /**
+ * The real file handler used to write fixtures.
+ *
+ * @var LogHandlerFileV2
+ */
+ private $handler;
+
+ /**
+ * A temporary log folder, so the tests never touch the site's own log files.
+ *
+ * @var string
+ */
+ private $log_directory;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->log_directory = trailingslashit( sys_get_temp_dir() ) . 'wc-step-log-' . wp_generate_uuid4() . '/';
+ wp_mkdir_p( $this->log_directory );
+ Constants::set_constant( 'WC_LOG_DIR_CUSTOM', true );
+ Constants::set_constant( 'WC_LOG_DIR', $this->log_directory );
+
+ $this->sut = new StepLogFileReader();
+ $this->handler = new LogHandlerFileV2();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ Constants::clear_single_constant( 'WC_LOG_DIR_CUSTOM' );
+ Constants::clear_single_constant( 'WC_LOG_DIR' );
+ FilesystemUtil::get_wp_filesystem_direct()->rmdir( $this->log_directory, true );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should read back lines written under a source, in time order, with their context decoded.
+ */
+ public function test_read_returns_lines_for_the_source_in_time_order(): void {
+ $now = time();
+ $this->handler->handle( $now - 10, 'info', 'Recipients: resolved', $this->context( 'push-token-4412', 'recipients', 'resolved' ) );
+ $this->handler->handle( $now - 20, 'info', 'Token excluded: notifications off', $this->context( 'push-token-4412', 'token_excluded', 'notifications_off' ) );
+ $this->handler->handle( $now - 5, 'info', 'Triggered: queued', $this->context( 'push-token-9999', 'triggered', 'queued' ) );
+
+ $rows = $this->sut->read( 'push-token-4412', $now - DAY_IN_SECONDS, $now )['rows'];
+
+ $this->assertCount( 2, $rows );
+ $this->assertSame( 'token_excluded', $rows[0]['context']['step'] );
+ $this->assertSame( 'recipients', $rows[1]['context']['step'] );
+ $this->assertSame( 'info', $rows[1]['level'] );
+ $this->assertSame( 'Recipients: resolved', $rows[1]['message'] );
+ $this->assertSame( $now - 10, $rows[1]['timestamp'] );
+ $this->assertNull( $rows[1]['raw'] );
+ }
+
+ /**
+ * @testdox Should include the previous day's file when the range starts part way through that day.
+ */
+ public function test_read_spans_days_from_a_mid_day_start(): void {
+ $yesterday_evening = (int) strtotime( gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) . 'T23:50:00+00:00' );
+ $this->handler->handle( $yesterday_evening, 'info', 'Triggered: queued', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+ $this->handler->handle( $yesterday_evening + 900, 'info', 'Loopback started: ok', $this->context( 'push-notifications-store-order', 'loopback_started', 'ok' ) );
+
+ $rows = $this->sut->read( 'push-notifications-store-order', $yesterday_evening - 60, $yesterday_evening + 3600 )['rows'];
+
+ $this->assertSame( array( 'triggered', 'loopback_started' ), array_column( array_column( $rows, 'context' ), 'step' ) );
+ }
+
+ /**
+ * @testdox Should stop at the newest day that fills the page, leaving older days unread.
+ */
+ public function test_read_stops_once_the_page_is_full(): void {
+ $today = (int) strtotime( gmdate( 'Y-m-d' ) . 'T10:00:00+00:00' );
+ $yesterday = $today - DAY_IN_SECONDS;
+
+ $this->handler->handle( $yesterday, 'info', 'Old one', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+ $this->handler->handle( $today, 'info', 'New one', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+ $this->handler->handle( $today + 1, 'info', 'New two', $this->context( 'push-notifications-store-order', 'triggered', 'queued' ) );
+
+ $result = $this->sut->read( 'push-notifications-store-order', $yesterday - HOUR_IN_SECONDS, $today + HOUR_IN_SECONDS, 2 );
+
+ $this->assertSame( array( 'New one', 'New two' ), array_column( $result['rows'], 'message' ) );
+ $this->assertSame(
+ (int) strtotime( gmdate( 'Y-m-d' ) . 'T00:00:00+00:00' ),
+ $result['covered_from'],
+ 'Coverage should start at the newest day, since the older one was never opened.'
+ );
+ $this->assertTrue( $result['has_more'], 'The unread day may hold older lines.' );
+ }
+
+ /**
+ * @testdox Should turn each line into the caller's rows as it reads, rather than collecting them all first.
+ */
+ public function test_read_expands_each_line_as_it_is_read(): void {
+ $now = time();
+ $this->handler->handle( $now - 60, 'info', 'Recipients: resolved', $this->context( 'push-notifications-store-order', 'recipients', 'resolved' ) );
+
+ $result = $this->sut->read(
+ 'push-notifications-store-order',
+ $now - HOUR_IN_SECONDS,
+ $now,
+ 100,
+ fn( array $row ) => array_fill( 0, 3, $row )
+ );
+
+ $this->assertCount( 3, $result['rows'] );
+ }
+
+ /**
+ * @testdox Should leave out lines outside the requested range.
+ */
+ public function test_read_filters_by_timestamp(): void {
+ $now = time();
+ $this->handler->handle( $now - 7200, 'info', 'Old', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+ $this->handler->handle( $now - 60, 'info', 'Recent', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+
+ $rows = $this->sut->read( 'push-token-1', $now - 3600, $now )['rows'];
+
+ $this->assertCount( 1, $rows );
+ $this->assertSame( 'Recent', $rows[0]['message'] );
+ }
+
+ /**
+ * @testdox Should return nothing for a source with no file.
+ */
+ public function test_read_returns_nothing_for_a_missing_file(): void {
+ $this->assertSame( array(), $this->sut->read( 'push-token-404', time() - DAY_IN_SECONDS, time() )['rows'] );
+ }
+
+ /**
+ * @testdox Should parse a well-formed line and return the raw text when the context is not JSON.
+ * @dataProvider provider_lines
+ *
+ * @param string $line The line.
+ * @param array|null $expected The expected row, or null.
+ */
+ public function test_parse_line( string $line, ?array $expected ): void {
+ $this->assertSame( $expected, StepLogFileReader::parse_line( $line ) );
+ }
+
+ /**
+ * Lines and their parsed form.
+ *
+ * @return array
+ */
+ public function provider_lines(): array {
+ return array(
+ 'with context' => array(
+ '2026-09-15T10:00:00+00:00 INFO Dispatched: accepted CONTEXT: {"step":"dispatched","outcome":"accepted"}' . "\n",
+ array(
+ 'timestamp' => 1789466400,
+ 'level' => 'info',
+ 'message' => 'Dispatched: accepted',
+ 'context' => array(
+ 'step' => 'dispatched',
+ 'outcome' => 'accepted',
+ ),
+ 'raw' => null,
+ ),
+ ),
+ 'without context' => array(
+ '2026-09-15T10:00:00+00:00 ERROR Something broke',
+ array(
+ 'timestamp' => 1789466400,
+ 'level' => 'error',
+ 'message' => 'Something broke',
+ 'context' => null,
+ 'raw' => null,
+ ),
+ ),
+ 'reshaped context' => array(
+ '2026-09-15T10:00:00+00:00 INFO Send: accepted CONTEXT: step=send outcome=accepted',
+ array(
+ 'timestamp' => 1789466400,
+ 'level' => 'info',
+ 'message' => 'Send: accepted',
+ 'context' => null,
+ 'raw' => '2026-09-15T10:00:00+00:00 INFO Send: accepted CONTEXT: step=send outcome=accepted',
+ ),
+ ),
+ 'no timestamp' => array( 'just some text here', null ),
+ 'unknown level' => array( '2026-09-15T10:00:00+00:00 LOUD Send: accepted', null ),
+ );
+ }
+
+ /**
+ * Builds a step logger context for a fixture line.
+ *
+ * @param string $source The log source.
+ * @param string $step The step.
+ * @param string $outcome The outcome.
+ * @return array
+ */
+ private function context( string $source, string $step, string $outcome ): array {
+ return array(
+ 'source' => $source,
+ 'identifier' => '1_store_order_42',
+ 'type' => 'store_order',
+ 'resource_id' => 42,
+ 'step' => $step,
+ 'outcome' => $outcome,
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogTableReaderTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogTableReaderTest.php
new file mode 100644
index 00000000000..f010e12ce48
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/DataStores/StepLogTableReaderTest.php
@@ -0,0 +1,158 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\DataStores;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogTableReader;
+use WC_Log_Handler_DB;
+use WC_Log_Levels;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StepLogTableReader class.
+ */
+class StepLogTableReaderTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var StepLogTableReader
+ */
+ private $sut;
+
+ /**
+ * The real database handler used to write fixtures.
+ *
+ * @var WC_Log_Handler_DB
+ */
+ private $handler;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new StepLogTableReader();
+ $this->handler = new WC_Log_Handler_DB();
+ }
+
+ /**
+ * @testdox Should read back rows written under a source, in time order, with their context decoded.
+ */
+ public function test_read_returns_rows_for_the_source(): void {
+ $now = time();
+ $this->handler->handle( $now - 20, 'info', 'Token excluded: notifications off', $this->context( 'push-token-4412', 'token_excluded', 'notifications_off' ) );
+ $this->handler->handle( $now - 15, 'info', 'Other source', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+ $this->handler->handle( $now - 10, 'error', 'Dispatched: failed', $this->context( 'push-token-4412', 'dispatched', 'failed' ) );
+
+ $result = $this->sut->read( 'push-token-4412', $now - DAY_IN_SECONDS, $now );
+
+ $this->assertCount( 2, $result['rows'] );
+ $this->assertSame( 'token_excluded', $result['rows'][0]['context']['step'] );
+ $this->assertSame( 'error', $result['rows'][1]['level'] );
+ $this->assertSame( 'Dispatched: failed', $result['rows'][1]['message'] );
+ $this->assertSame( $now - 10, $result['rows'][1]['timestamp'] );
+ $this->assertSame( $now - DAY_IN_SECONDS, $result['covered_from'], 'A table the scan fully covers reports the requested start.' );
+ }
+
+ /**
+ * @testdox Should leave out rows outside the requested range.
+ */
+ public function test_read_filters_by_timestamp(): void {
+ $now = time();
+ $this->handler->handle( $now - 7200, 'info', 'Old', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+ $this->handler->handle( $now - 60, 'info', 'Recent', $this->context( 'push-token-1', 'dispatched', 'accepted' ) );
+
+ $result = $this->sut->read( 'push-token-1', $now - 3600, $now );
+
+ $this->assertCount( 1, $result['rows'] );
+ $this->assertSame( 'Recent', $result['rows'][0]['message'] );
+ }
+
+ /**
+ * @testdox Should return nothing when the table is empty.
+ */
+ public function test_read_returns_nothing_for_an_empty_table(): void {
+ $result = $this->sut->read( 'push-token-404', time() - DAY_IN_SECONDS, time() );
+
+ $this->assertSame( array(), $result['rows'] );
+ }
+
+ /**
+ * @testdox Should read on into the next window when the page's last second carries on there.
+ */
+ public function test_read_does_not_stop_partway_through_a_second(): void {
+ $now = time();
+ $edge = $this->next_free_log_id() + 10;
+ $top = $edge + StepLogTableReader::WINDOW_SIZE;
+
+ // Three newer lines, then five lines of one second on both sides of the edge between two windows.
+ foreach ( array( $top, $top - 1, $top - 2 ) as $offset => $log_id ) {
+ $this->insert_row( $log_id, $now - 1 - $offset, 'Newer ' . $offset );
+ }
+
+ foreach ( range( $edge - 2, $edge + 2 ) as $log_id ) {
+ $this->insert_row( $log_id, $now - 10, 'Split ' . $log_id );
+ }
+
+ $rows = $this->sut->read( 'push-token-1', $now - HOUR_IN_SECONDS, $now, 5 )['rows'];
+
+ $this->assertCount( 5, array_filter( $rows, fn( array $row ) => $now - 10 === $row['timestamp'] ), 'Every line of the second on the window edge should be read.' );
+ }
+
+ /**
+ * Returns an ID above every row in the log table, for rows placed by ID.
+ *
+ * @return int
+ */
+ private function next_free_log_id(): int {
+ global $wpdb;
+
+ return (int) $wpdb->get_var( "SELECT COALESCE( MAX(log_id), 0 ) FROM {$wpdb->prefix}woocommerce_log" ) + 1; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ }
+
+ /**
+ * Writes one `push-token-1` row at a chosen ID, as the database handler would.
+ *
+ * @param int $log_id The row ID.
+ * @param int $timestamp The row's timestamp.
+ * @param string $message The message.
+ * @return void
+ */
+ private function insert_row( int $log_id, int $timestamp, string $message ): void {
+ global $wpdb;
+
+ $wpdb->insert(
+ $wpdb->prefix . 'woocommerce_log',
+ array(
+ 'log_id' => $log_id,
+ 'timestamp' => gmdate( 'Y-m-d H:i:s', $timestamp ),
+ 'level' => WC_Log_Levels::get_level_severity( 'info' ),
+ 'source' => 'push-token-1',
+ 'message' => $message,
+ ),
+ array( '%d', '%s', '%d', '%s', '%s' )
+ );
+ }
+
+ /**
+ * Builds a step logger context for a fixture row.
+ *
+ * @param string $source The log source.
+ * @param string $step The step.
+ * @param string $outcome The outcome.
+ * @return array
+ */
+ private function context( string $source, string $step, string $outcome ): array {
+ return array(
+ 'source' => $source,
+ 'identifier' => '1_store_order_42',
+ 'type' => 'store_order',
+ 'resource_id' => 42,
+ 'step' => $step,
+ 'outcome' => $outcome,
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StepLogQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StepLogQueryTest.php
new file mode 100644
index 00000000000..6dbc4f37b60
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/StepLogQueryTest.php
@@ -0,0 +1,586 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogFileReader;
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\StepLogTableReader;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationStepLogger;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\StepLogQuery;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the StepLogQuery class.
+ */
+class StepLogQueryTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var StepLogQuery
+ */
+ private $sut;
+
+ /**
+ * Mock file reader.
+ *
+ * @var StepLogFileReader|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $file_reader;
+
+ /**
+ * Mock table reader.
+ *
+ * @var StepLogTableReader|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $table_reader;
+
+ /**
+ * Mock data store.
+ *
+ * @var PushTokensDataStore|\PHPUnit\Framework\MockObject\MockObject
+ */
+ private $data_store;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->file_reader = $this->createMock( StepLogFileReader::class );
+ $this->table_reader = $this->createMock( StepLogTableReader::class );
+ $this->data_store = $this->createMock( PushTokensDataStore::class );
+ $this->data_store->method( 'count_tokens' )->willReturn( 3 );
+
+ $step_logger = new NotificationStepLogger();
+
+ $this->sut = new StepLogQuery();
+ $this->sut->init( $this->file_reader, $this->table_reader, $this->data_store, $step_logger );
+ }
+
+ /**
+ * @testdox Should read the type's journey file and the module's error source, keeping only the resource's rows.
+ */
+ public function test_for_notification_reads_both_sources_and_filters_by_resource(): void {
+ $this->file_reader_returns(
+ array(
+ array(
+ 'push-notifications-store-order',
+ 100,
+ 200,
+ array(
+ $this->row( 150, 'store_order', 42, 'triggered', 'queued' ),
+ $this->row( 151, 'store_order', 43, 'triggered', 'queued' ),
+ ),
+ ),
+ array(
+ 'push-suppressed',
+ 100,
+ 200,
+ array(
+ $this->row( 155, 'store_order', 42, 'token_excluded', 'notifications_off', 'info', 4412, 7 ),
+ $this->row( 156, 'store_order', 43, 'token_excluded', 'notifications_off', 'info', 4413, 8 ),
+ ),
+ ),
+ array(
+ 'push_notifications',
+ 100,
+ 200,
+ array(
+ $this->row( 160, 'store_order', 42, 'dispatched', 'failed', 'error' ),
+ ),
+ ),
+ )
+ );
+
+ $result = $this->sut->for_notification( 'store_order', 42, 100, 200, 100 );
+
+ $this->assertCount( 3, $result['rows'] );
+ $this->assertSame( 'error', $result['rows'][0]['level'] );
+ $this->assertSame( gmdate( 'c', 160 ), $result['rows'][0]['timestamp'] );
+ $this->assertSame( 'token_excluded', $result['rows'][1]['context']['step'] );
+ $this->assertSame( 'triggered', $result['rows'][2]['context']['step'] );
+ $this->assertNull( $result['next_to'] );
+ $this->assertEquals(
+ array(
+ 'triggered:queued' => 1,
+ 'token_excluded:notifications_off' => 1,
+ 'dispatched:failed' => 1,
+ ),
+ $result['counts']
+ );
+ $this->assertSame( 100, $result['covered_from'] );
+ }
+
+ /**
+ * @testdox Should read every notification type, the suppressed source and the error source, and no source per token.
+ */
+ public function test_for_site_reads_every_source_the_store_holds(): void {
+ $read = array();
+ $this->file_reader->method( 'read' )->willReturnCallback(
+ function ( string $source, int $from ) use ( &$read ): array {
+ $read[] = $source;
+
+ return array(
+ 'rows' => array(),
+ 'covered_from' => $from,
+ 'has_more' => false,
+ );
+ }
+ );
+
+ $this->sut->for_site( 100, 200, 100 );
+
+ $this->assertEqualsCanonicalizing(
+ array(
+ 'push-notifications-store-order',
+ 'push-notifications-store-review',
+ 'push-notifications-store-stock',
+ 'push-suppressed',
+ 'push_notifications',
+ ),
+ $read
+ );
+ }
+
+ /**
+ * @testdox Should find one token named inside the notification lines and return a row for it.
+ */
+ public function test_for_token_finds_the_token_named_in_the_aggregate_lines(): void {
+ $this->reader_rows_from(
+ function ( string $source ) {
+ if ( 'push-notifications-store-order' === $source ) {
+ return array(
+ $this->aggregate_row( 180, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412, 9999 ) ) ),
+ $this->aggregate_row( 170, 'store_order', 42, 'dispatched', 'invalid_token', array( 'invalid_token_ids' => array( 9999 ) ) ),
+ );
+ }
+
+ if ( 'push-suppressed' === $source ) {
+ return array(
+ $this->aggregate_row(
+ 150,
+ 'store_order',
+ 41,
+ 'token_excluded',
+ 'notifications_off',
+ array(
+ 'excluded_tokens' => array(
+ 'notifications_off' => array( 4412 ),
+ 'order_missing' => array( 9999 ),
+ ),
+ )
+ ),
+ );
+ }
+
+ return array();
+ }
+ );
+
+ $result = $this->sut->for_token( 4412, 100, 200, 100 );
+
+ $this->assertSame( array( gmdate( 'c', 180 ), gmdate( 'c', 150 ) ), array_column( $result['rows'], 'timestamp' ) );
+ $this->assertSame( 'token_included', $result['rows'][0]['context']['step'] );
+ $this->assertSame( 4412, $result['rows'][0]['context']['token_id'] );
+ $this->assertArrayNotHasKey( 'token_ids', $result['rows'][0]['context'] );
+ $this->assertSame( 'token_excluded', $result['rows'][1]['context']['step'] );
+ $this->assertSame( 'notifications_off', $result['rows'][1]['context']['outcome'] );
+ }
+
+ /**
+ * @testdox Should return a row per token the user owns when one line names several of them.
+ */
+ public function test_for_user_returns_a_row_per_token_named(): void {
+ $this->data_store->method( 'get_token_ids_for_user' )->with( 7 )->willReturn( array( 11, 12 ) );
+ $this->reader_rows_from(
+ function ( string $source ) {
+ if ( 'push-notifications-store-order' === $source ) {
+ return array(
+ $this->aggregate_row( 180, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 11, 12, 99 ) ) ),
+ );
+ }
+
+ return array();
+ }
+ );
+
+ $result = $this->sut->for_user( 7, 100, 200, 100 );
+
+ $this->assertCount( 2, $result['rows'] );
+ $this->assertSame( array( 11, 12 ), array_column( array_column( $result['rows'], 'context' ), 'token_id' ) );
+ }
+
+ /**
+ * @testdox Should include the batch loopback line covering this notification, which carries no identity of its own.
+ */
+ public function test_for_notification_includes_the_batch_line_covering_it(): void {
+ $batch_line = array(
+ 'timestamp' => 150,
+ 'level' => 'info',
+ 'message' => 'Loopback requested: ok',
+ 'context' => array(
+ 'step' => 'loopback_requested',
+ 'outcome' => 'ok',
+ 'batch_size' => 2,
+ 'notifications' => array(
+ array(
+ 'type' => 'store_order',
+ 'resource_id' => 42,
+ ),
+ array(
+ 'type' => 'store_order',
+ 'resource_id' => 43,
+ ),
+ ),
+ ),
+ 'raw' => null,
+ );
+
+ $this->reader_rows_from( fn( string $source ) => 'push_notifications' === $source ? array( $batch_line ) : array() );
+
+ $this->assertCount( 1, $this->sut->for_notification( 'store_order', 42, 100, 200, 100 )['rows'] );
+ $this->assertCount( 0, $this->sut->for_notification( 'store_order', 99, 100, 200, 100 )['rows'] );
+ }
+
+ /**
+ * @testdox Should order lines sharing a second by the lifecycle, not by the order they were written.
+ */
+ public function test_lines_sharing_a_second_are_ordered_by_the_lifecycle(): void {
+ $this->file_reader_returns(
+ array(
+ array(
+ 'push-notifications-store-order',
+ 100,
+ 200,
+ array(
+ $this->row( 150, 'store_order', 42, 'loopback_started', 'ok' ),
+ $this->row( 150, 'store_order', 42, 'dispatched', 'accepted' ),
+ $this->row( 150, 'store_order', 42, 'recipients', 'resolved' ),
+ $this->row( 150, 'store_order', 42, 'loopback_requested', 'ok' ),
+ ),
+ ),
+ array( 'push-suppressed', 100, 200, array() ),
+ array( 'push_notifications', 100, 200, array() ),
+ )
+ );
+
+ $result = $this->sut->for_notification( 'store_order', 42, 100, 200, 100 );
+
+ $this->assertSame(
+ array( 'dispatched', 'recipients', 'loopback_started', 'loopback_requested' ),
+ array_column( array_column( $result['rows'], 'context' ), 'step' )
+ );
+ }
+
+ /**
+ * @testdox Should return one page newest first, with a cursor one second before the oldest row kept.
+ */
+ public function test_a_page_is_capped_and_carries_a_cursor(): void {
+ $this->file_reader_returns(
+ array(
+ array(
+ 'push-notifications-store-order',
+ 100,
+ 200,
+ array(
+ $this->row( 150, 'store_order', 42, 'triggered', 'queued' ),
+ $this->row( 160, 'store_order', 42, 'loopback_requested', 'ok' ),
+ $this->row( 170, 'store_order', 42, 'dispatched', 'accepted' ),
+ ),
+ ),
+ array( 'push-suppressed', 100, 200, array() ),
+ array( 'push_notifications', 100, 200, array() ),
+ )
+ );
+
+ $result = $this->sut->for_notification( 'store_order', 42, 100, 200, 2 );
+
+ $this->assertSame( array( gmdate( 'c', 170 ), gmdate( 'c', 160 ) ), array_column( $result['rows'], 'timestamp' ) );
+ $this->assertSame( 159, $result['next_to'] );
+ $this->assertEquals(
+ array(
+ 'dispatched:accepted' => 1,
+ 'loopback_requested:ok' => 1,
+ ),
+ $result['counts']
+ );
+ }
+
+ /**
+ * @testdox Should carry every row of the page's last second, even past the limit.
+ */
+ public function test_a_page_never_ends_partway_through_a_second(): void {
+ $this->file_reader_returns(
+ array(
+ array(
+ 'push-notifications-store-order',
+ 100,
+ 200,
+ array(
+ $this->row( 150, 'store_order', 42, 'triggered', 'queued' ),
+ $this->row( 160, 'store_order', 42, 'loopback_requested', 'ok' ),
+ $this->row( 160, 'store_order', 42, 'loopback_started', 'ok' ),
+ $this->row( 160, 'store_order', 42, 'recipients', 'resolved' ),
+ ),
+ ),
+ array( 'push-suppressed', 100, 200, array() ),
+ array( 'push_notifications', 100, 200, array() ),
+ )
+ );
+
+ $result = $this->sut->for_notification( 'store_order', 42, 100, 200, 2 );
+
+ $this->assertCount( 3, $result['rows'] );
+ $this->assertSame( array( 160, 160, 160 ), array_map( fn( array $row ) => (int) strtotime( $row['timestamp'] ), $result['rows'] ) );
+ $this->assertSame( 159, $result['next_to'] );
+ }
+
+ /**
+ * @testdox Should report no cursor when the range holds nothing older.
+ */
+ public function test_a_page_holding_everything_has_no_cursor(): void {
+ $this->file_reader_returns(
+ array(
+ array(
+ 'push-notifications-store-order',
+ 100,
+ 200,
+ array( $this->aggregate_row( 150, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ) ),
+ ),
+ array( 'push-notifications-store-review', 100, 200, array() ),
+ array( 'push-notifications-store-stock', 100, 200, array() ),
+ array( 'push-suppressed', 100, 200, array() ),
+ )
+ );
+
+ $result = $this->sut->for_token( 4412, 100, 200, 100 );
+
+ $this->assertCount( 1, $result['rows'] );
+ $this->assertNull( $result['next_to'] );
+ }
+
+ /**
+ * @testdox Should read the table instead of files, and report its coverage, when the store uses the database handler.
+ */
+ public function test_uses_the_table_reader_for_the_database_handler(): void {
+ add_filter( 'pre_option_woocommerce_logs_default_handler', fn() => 'WC_Log_Handler_DB' );
+
+ $read = 0;
+
+ $this->file_reader->expects( $this->never() )->method( 'read' );
+ $this->reader_rows_from(
+ function ( string $source ) use ( &$read ): array {
+ ++$read;
+
+ return 'push-notifications-store-order' === $source
+ ? array( $this->aggregate_row( 150, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ) )
+ : array();
+ },
+ $this->table_reader
+ );
+
+ $result = $this->sut->for_token( 4412, 100, 200, 100 );
+
+ $this->assertCount( 1, $result['rows'] );
+ $this->assertSame( 4, $read, 'Every source a token read covers should be read from the table.' );
+ }
+
+ /**
+ * @testdox Should return the store's logging state with every answer.
+ */
+ public function test_returns_the_logging_state(): void {
+ $this->file_reader->method( 'read' )->willReturn(
+ array(
+ 'rows' => array(),
+ 'covered_from' => 0,
+ 'has_more' => false,
+ )
+ );
+
+ $state = $this->sut->for_token( 4412, 100, 200, 100 )['logging'];
+
+ $this->assertTrue( $state['logging_enabled'] );
+ $this->assertTrue( $state['push_logging_enabled'] );
+ $this->assertSame( 'none', $state['level_threshold'] );
+ $this->assertSame( 30, $state['retention_period_days'] );
+ $this->assertSame( 3, $state['token_count'] );
+ $this->assertSame( NotificationProcessor::RECIPIENT_ID_CAP, $state['recipient_id_cap'] );
+ $this->assertArrayHasKey( 'default_handler', $state );
+ $this->assertArrayHasKey( 'log_directory_writable', $state );
+ }
+
+ /**
+ * @testdox Should offer the next page when one reader stopped at the limit, even with no row left over.
+ */
+ public function test_a_reader_that_stopped_at_the_limit_still_gives_a_cursor(): void {
+ $this->reader_rows_from(
+ function ( string $source ) {
+ return 'push-notifications-store-order' === $source
+ ? array(
+ $this->aggregate_row( 180, 'store_order', 42, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ),
+ $this->aggregate_row( 170, 'store_order', 41, 'recipients', 'resolved', array( 'token_ids' => array( 4412 ) ) ),
+ )
+ : array();
+ },
+ null,
+ array( 'push-notifications-store-order' )
+ );
+
+ $result = $this->sut->for_token( 4412, 100, 200, 2 );
+
+ $this->assertCount( 2, $result['rows'] );
+ $this->assertSame( 169, $result['next_to'], 'The cursor should start one second before the oldest row on the page.' );
+ }
+
+ /**
+ * @testdox Should count a failure once when both its error line and its step line are on the page.
+ */
+ public function test_a_failure_logged_twice_counts_once(): void {
+ $this->file_reader_returns(
+ array(
+ array( 'push-notifications-store-order', 100, 200, array( $this->row( 160, 'store_order', 42, 'dispatched', 'request_failed' ) ) ),
+ array( 'push_notifications', 100, 200, array( $this->row( 160, 'store_order', 42, 'dispatched', 'request_failed', 'error' ) ) ),
+ )
+ );
+
+ $result = $this->sut->for_notification( 'store_order', 42, 100, 200, 100 );
+
+ $this->assertCount( 2, $result['rows'], 'Both lines should still be returned.' );
+ $this->assertSame( 1, $result['counts']['dispatched:request_failed'] );
+ }
+
+ /**
+ * Makes a reader answer from a function of source to rows, applying the
+ * expansion and returning the coverage a real reader now returns.
+ *
+ * @param callable $rows_for_source Called with a source, returning its rows.
+ * @param MockObject|null $reader The reader to stub, defaulting to the file reader.
+ * @param string[] $sources_with_more Sources whose reader reports it stopped at the limit.
+ * @return void
+ */
+ private function reader_rows_from( callable $rows_for_source, $reader = null, array $sources_with_more = array() ): void {
+ $reader = $reader ?? $this->file_reader;
+
+ $reader->method( 'read' )->willReturnCallback(
+ function ( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ) use ( $rows_for_source, $sources_with_more ): array {
+ $rows = array();
+
+ foreach ( $rows_for_source( $source ) as $row ) {
+ if ( null === $expand ) {
+ $rows[] = $row;
+ continue;
+ }
+
+ foreach ( $expand( $row ) as $expanded ) {
+ $rows[] = $expanded;
+ }
+ }
+
+ return array(
+ 'rows' => $rows,
+ 'covered_from' => $from,
+ 'has_more' => in_array( $source, $sources_with_more, true ),
+ );
+ }
+ );
+ }
+
+ /**
+ * Makes the file reader answer from the same shape willReturnMap took,
+ * applying the expansion and returning the coverage the real reader now
+ * returns, since the filtering happens as each line is read.
+ *
+ * @param array<int, array{0: string, 1: int, 2: int, 3: array}> $map Source, from, to and the rows to return.
+ * @return void
+ */
+ private function file_reader_returns( array $map ): void {
+ $by_source = array();
+
+ foreach ( $map as $entry ) {
+ $by_source[ $entry[0] ] = $entry[3];
+ }
+
+ $this->file_reader->method( 'read' )->willReturnCallback(
+ function ( string $source, int $from, int $to, int $limit = PHP_INT_MAX, ?callable $expand = null ) use ( $by_source ): array {
+ $rows = array();
+
+ foreach ( $by_source[ $source ] ?? array() as $row ) {
+ if ( null === $expand ) {
+ $rows[] = $row;
+ continue;
+ }
+
+ foreach ( $expand( $row ) as $expanded ) {
+ $rows[] = $expanded;
+ }
+ }
+
+ return array(
+ 'rows' => $rows,
+ 'covered_from' => $from,
+ 'has_more' => false,
+ );
+ }
+ );
+ }
+
+ /**
+ * Builds a row whose context names tokens in arrays, as the aggregated
+ * lines do.
+ *
+ * @param int $timestamp The line's timestamp.
+ * @param string $type The notification type.
+ * @param int $resource_id The order, comment or product ID.
+ * @param string $step The step name.
+ * @param string $outcome The outcome.
+ * @param array $extra Context fields naming the tokens.
+ * @return array
+ */
+ private function aggregate_row( int $timestamp, string $type, int $resource_id, string $step, string $outcome, array $extra ): array {
+ $row = $this->row( $timestamp, $type, $resource_id, $step, $outcome );
+ $row['context'] = array_merge( $row['context'], $extra );
+
+ return $row;
+ }
+
+ /**
+ * Builds a parsed row as the readers return it.
+ *
+ * @param int $timestamp The line's timestamp.
+ * @param string $type The notification type.
+ * @param int $resource_id The order, comment or product ID.
+ * @param string $step The step name.
+ * @param string $outcome The outcome.
+ * @param string $level The log level.
+ * @param int|null $token_id The push token post ID, for a per-token row.
+ * @param int|null $user_id The user who owns the token.
+ * @return array
+ */
+ private function row( int $timestamp, string $type, int $resource_id, string $step, string $outcome, string $level = 'info', ?int $token_id = null, ?int $user_id = null ): array {
+ $context = array(
+ 'identifier' => '1_' . $type . '_' . $resource_id,
+ 'type' => $type,
+ 'resource_id' => $resource_id,
+ 'step' => $step,
+ 'outcome' => $outcome,
+ );
+
+ if ( null !== $token_id ) {
+ $context['token_id'] = $token_id;
+ $context['user_id'] = $user_id;
+ }
+
+ return array(
+ 'timestamp' => $timestamp,
+ 'level' => $level,
+ 'message' => $step . ': ' . $outcome,
+ 'context' => $context,
+ 'raw' => null,
+ );
+ }
+}