Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.86% covered (success)
90.86%
179 / 197
54.55% covered (warning)
54.55%
12 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stats
91.79% covered (success)
91.79%
179 / 195
54.55% covered (warning)
54.55%
12 / 22
85.71
0.00% covered (danger)
0.00%
0 / 1
 table_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 schema
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 bucket
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 add
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 add_count
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 move
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 backfill_counts
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 series
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 totals_by_source
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 origins
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
8.01
 sent_counts
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 has_history
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 since
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 newest
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 log_still_counts
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 uncounted
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 count_log_rows
95.83% covered (success)
95.83%
23 / 24
0.00% covered (danger)
0.00%
0 / 1
6
 backfill_state
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 start_backfill
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 backfill_batch
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
10.05
 prune
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 drop
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Email counts for the dashboard.
4 *
5 * @package automattic/jetpack-send
6 */
7
8namespace Automattic\Jetpack\Send;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- A custom counts table; GET /stats caches the results.
15
16/**
17 * One row per site-local hour, status, type and plugin, holding a number. The counts outlive the
18 * log, so a short retention never empties the dashboard. No addresses, subjects or row ids.
19 *
20 * Product code only ever adds, except move() when one email changes status.
21 */
22class Stats {
23
24    /**
25     * Days of counts kept: a year's range plus the year before it.
26     *
27     * @var int
28     */
29    const KEEP_DAYS = 800;
30
31    /**
32     * `{ until_id, cursor, done }` for counting the emails logged before the counts existed.
33     * Kept once done: it's the flag that stops a second upgrade counting them again.
34     *
35     * @var string
36     */
37    const BACKFILL_OPTION = 'jetpack_send_stats_backfill';
38
39    /**
40     * Log rows read per backfill batch.
41     *
42     * @var int
43     */
44    const BACKFILL_ROWS = 5000;
45
46    /**
47     * The statuses counted, in the order the dashboard lists them.
48     *
49     * @var string[]
50     */
51    const STATUSES = array( Email_Log::STATUS_SENT, Email_Log::STATUS_FALLBACK, Email_Log::STATUS_FAILED );
52
53    /**
54     * Table name.
55     *
56     * @return string
57     */
58    public static function table_name() {
59        global $wpdb;
60        return $wpdb->prefix . 'jetpack_send_stats';
61    }
62
63    /**
64     * The table's definition, for dbDelta().
65     *
66     * @param string $charset Charset and collation clause.
67     * @return string
68     */
69    public static function schema( $charset ) {
70        $table = self::table_name();
71        return "CREATE TABLE {$table} (
72                bucket int(10) unsigned NOT NULL,
73                status varchar(20) NOT NULL,
74                source varchar(20) NOT NULL,
75                origin varchar(100) NOT NULL DEFAULT '',
76                emails int(10) unsigned NOT NULL DEFAULT 0,
77                PRIMARY KEY  (bucket,status,source,origin)
78            ) {$charset};";
79    }
80
81    /**
82     * The start of the site-local hour a time falls in, as a Unix time. Half-hour zones land
83     * local midnights on bucket edges, and daylight-saving days have 23 or 25 buckets.
84     *
85     * @since $$next-version$$
86     *
87     * @param int                $time Unix time.
88     * @param \DateTimeZone|null $zone Site timezone, when the caller already has it.
89     * @return int
90     */
91    public static function bucket( $time, $zone = null ) {
92        $time   = (int) $time;
93        $zone   = $zone instanceof \DateTimeZone ? $zone : wp_timezone();
94        $offset = $zone->getOffset( new \DateTime( '@' . $time ) );
95        return (int) ( floor( ( $time + $offset ) / HOUR_IN_SECONDS ) * HOUR_IN_SECONDS - $offset );
96    }
97
98    /**
99     * Count one email once its outcome is known.
100     *
101     * @since $$next-version$$
102     *
103     * @param int    $time   When the email's log row was created, Unix time.
104     * @param string $status `sent`, `fallback` or `failed`.
105     * @param string $source `wp_mail`, `test` or `resend`.
106     * @param string $origin What sent it; '' for tests.
107     * @param int    $log_id The log row; 0 when the insert failed, which still counts.
108     * @return void
109     */
110    public static function add( $time, $status, $source, $origin, $log_id = 0 ) {
111        if ( self::backfill_counts( $log_id ) ) {
112            return;
113        }
114        self::add_count( $time, $status, $source, $origin, 1 );
115    }
116
117    /**
118     * Add to a count, for one or more emails at once.
119     *
120     * @since $$next-version$$
121     *
122     * @param int    $time   Unix time.
123     * @param string $status Status.
124     * @param string $source Type.
125     * @param string $origin Origin key.
126     * @param int    $n      Emails to add.
127     * @return void
128     */
129    public static function add_count( $time, $status, $source, $origin, $n ) {
130        global $wpdb;
131        if ( (int) $n < 1 || ! Email_Log::has_v4_schema() ) {
132            return;
133        }
134        $wpdb->query(
135            $wpdb->prepare(
136                'INSERT INTO %i ( bucket, status, source, origin, emails ) VALUES ( %d, %s, %s, %s, %d ) ON DUPLICATE KEY UPDATE emails = emails + %d',
137                self::table_name(),
138                self::bucket( $time ),
139                substr( (string) $status, 0, 20 ),
140                substr( (string) $source, 0, 20 ),
141                substr( (string) $origin, 0, 100 ),
142                (int) $n,
143                (int) $n
144            )
145        );
146    }
147
148    /**
149     * Move one counted email to another status, e.g. when the host's mail fails after a hand-off.
150     *
151     * @since $$next-version$$
152     *
153     * @param int    $time   When its log row was created, Unix time.
154     * @param string $from   Status it was counted as.
155     * @param string $to     Status it has now.
156     * @param string $source Type.
157     * @param string $origin Origin key.
158     * @param int    $log_id The log row.
159     * @return void
160     */
161    public static function move( $time, $from, $to, $source, $origin, $log_id = 0 ) {
162        global $wpdb;
163        if ( ! Email_Log::has_v4_schema() || self::backfill_counts( $log_id ) ) {
164            return;
165        }
166        $wpdb->query(
167            $wpdb->prepare(
168                'UPDATE %i SET emails = emails - 1 WHERE bucket = %d AND status = %s AND source = %s AND origin = %s AND emails > 0',
169                self::table_name(),
170                self::bucket( $time ),
171                (string) $from,
172                (string) $source,
173                substr( (string) $origin, 0, 100 )
174            )
175        );
176        self::add_count( $time, $to, $source, $origin, 1 );
177        Email_Log::clear_stats_cache();
178    }
179
180    /**
181     * Whether the backfill counts a row, so a live count would count it twice.
182     *
183     * @param int $log_id Log row id.
184     * @return bool
185     */
186    private static function backfill_counts( $log_id ) {
187        $state = self::backfill_state();
188        return null !== $state && (int) $log_id > 0 && (int) $log_id <= $state['until_id'];
189    }
190
191    /**
192     * Site emails and resends per hour and status; tests aren't counted.
193     *
194     * @since $$next-version$$
195     *
196     * @param int $start_utc First second, Unix time.
197     * @param int $end_utc   Second after the last one.
198     * @return array<int, array{sent: int, fallback: int, failed: int}> By bucket, only those with emails.
199     */
200    public static function series( $start_utc, $end_utc ) {
201        global $wpdb;
202        $series = array();
203        $fold   = static function ( $bucket, $status, $n ) use ( &$series ) {
204            if ( in_array( $status, self::STATUSES, true ) ) {
205                $series[ $bucket ]           ??= array_fill_keys( self::STATUSES, 0 );
206                $series[ $bucket ][ $status ] += $n;
207            }
208        };
209        if ( Email_Log::has_v4_schema() ) {
210            $rows = $wpdb->get_results( $wpdb->prepare( "SELECT bucket, status, SUM(emails) AS n FROM %i WHERE bucket >= %d AND bucket < %d AND source <> 'test' GROUP BY bucket, status", self::table_name(), $start_utc, $end_utc ), ARRAY_A );
211            foreach ( (array) $rows as $row ) {
212                $fold( (int) $row['bucket'], (string) $row['status'], (int) $row['n'] );
213            }
214        }
215        foreach ( self::uncounted( $start_utc, $end_utc ) as $row ) {
216            if ( 'test' !== $row['source'] ) {
217                $fold( $row['bucket'], $row['status'], $row['n'] );
218            }
219        }
220        ksort( $series );
221        return $series;
222    }
223
224    /**
225     * Every email by type and status, tests included.
226     *
227     * @since $$next-version$$
228     *
229     * @param int $start_utc First second, Unix time.
230     * @param int $end_utc   Second after the last one.
231     * @return array<string, array<string, int>> By type, then status.
232     */
233    public static function totals_by_source( $start_utc, $end_utc ) {
234        global $wpdb;
235        $totals = array();
236        if ( Email_Log::has_v4_schema() ) {
237            $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT source, status, SUM(emails) AS n FROM %i WHERE bucket >= %d AND bucket < %d GROUP BY source, status', self::table_name(), $start_utc, $end_utc ), ARRAY_A );
238            foreach ( (array) $rows as $row ) {
239                $totals[ (string) $row['source'] ][ (string) $row['status'] ] = (int) $row['n'];
240            }
241        }
242        foreach ( self::uncounted( $start_utc, $end_utc ) as $row ) {
243            $totals[ $row['source'] ][ $row['status'] ] = ( $totals[ $row['source'] ][ $row['status'] ] ?? 0 ) + $row['n'];
244        }
245        return $totals;
246    }
247
248    /**
249     * Site emails and resends per origin, most first. Tests and emails with no origin are left out.
250     *
251     * @since $$next-version$$
252     *
253     * @param int $start_utc First second, Unix time.
254     * @param int $end_utc   Second after the last one.
255     * @return array<int, array{origin: string, n: int}>
256     */
257    public static function origins( $start_utc, $end_utc ) {
258        global $wpdb;
259        $counts = array();
260        if ( Email_Log::has_v4_schema() ) {
261            $rows = $wpdb->get_results( $wpdb->prepare( "SELECT origin, SUM(emails) AS n FROM %i WHERE bucket >= %d AND bucket < %d AND source <> 'test' AND origin <> '' GROUP BY origin", self::table_name(), $start_utc, $end_utc ), ARRAY_A );
262            foreach ( (array) $rows as $row ) {
263                $counts[ (string) $row['origin'] ] = (int) $row['n'];
264            }
265        }
266        foreach ( self::uncounted( $start_utc, $end_utc ) as $row ) {
267            if ( 'test' !== $row['source'] && '' !== $row['origin'] ) {
268                $counts[ $row['origin'] ] = ( $counts[ $row['origin'] ] ?? 0 ) + $row['n'];
269            }
270        }
271        arsort( $counts );
272        $origins = array();
273        foreach ( $counts as $origin => $n ) {
274            if ( $n > 0 ) {
275                $origins[] = array(
276                    'origin' => (string) $origin,
277                    'n'      => $n,
278                );
279            }
280        }
281        return $origins;
282    }
283
284    /**
285     * Emails the mail service accepted since a time, every type: tests and resends use the plan's sends too.
286     *
287     * @since $$next-version$$
288     *
289     * @param int $start_utc First second, Unix time.
290     * @return array{emails: int, tests: int}
291     */
292    public static function sent_counts( $start_utc ) {
293        $end    = time() + DAY_IN_SECONDS;
294        $totals = self::totals_by_source( (int) $start_utc, $end );
295        $emails = 0;
296        foreach ( $totals as $statuses ) {
297            $emails += (int) ( $statuses[ Email_Log::STATUS_SENT ] ?? 0 );
298        }
299        return array(
300            'emails' => $emails,
301            'tests'  => (int) ( $totals['test'][ Email_Log::STATUS_SENT ] ?? 0 ),
302        );
303    }
304
305    /**
306     * Whether anything but a test was ever counted. Stops at the first one.
307     *
308     * @since $$next-version$$
309     *
310     * @return bool
311     */
312    public static function has_history() {
313        global $wpdb;
314        if ( Email_Log::has_v4_schema() && null !== $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM %i WHERE source <> 'test' AND emails > 0 LIMIT 1", self::table_name() ) ) ) {
315            return true;
316        }
317        return self::log_still_counts() && Email_Log::has_history();
318    }
319
320    /**
321     * The oldest counted hour, tests included.
322     *
323     * @since $$next-version$$
324     *
325     * @return int|null Bucket, or null with no counts.
326     */
327    public static function since() {
328        global $wpdb;
329        $since = null;
330        if ( Email_Log::has_v4_schema() ) {
331            $value = $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(bucket) FROM %i WHERE emails > 0', self::table_name() ) );
332            $since = null === $value ? null : (int) $value;
333        }
334        $oldest = self::log_still_counts() ? Email_Log::oldest_created_at() : null;
335        if ( null !== $oldest ) {
336            $bucket = self::bucket( (int) strtotime( $oldest . ' UTC' ) );
337            $since  = null === $since ? $bucket : min( $since, $bucket );
338        }
339        return $since;
340    }
341
342    /**
343     * The newest counted hour with a site email or resend.
344     *
345     * @since $$next-version$$
346     *
347     * @return int|null Bucket, or null with none.
348     */
349    public static function newest() {
350        global $wpdb;
351        $newest = null;
352        if ( Email_Log::has_v4_schema() ) {
353            $value  = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(bucket) FROM %i WHERE source <> 'test' AND emails > 0", self::table_name() ) );
354            $newest = null === $value ? null : (int) $value;
355        }
356        $last = self::log_still_counts() ? Email_Log::last_email_at() : null;
357        if ( null !== $last ) {
358            $bucket = self::bucket( (int) strtotime( $last . ' UTC' ) );
359            $newest = null === $newest ? $bucket : max( $newest, $bucket );
360        }
361        return $newest;
362    }
363
364    /**
365     * Whether some emails are still only in the log: before the upgrade, or while the backfill runs.
366     *
367     * @return bool
368     */
369    private static function log_still_counts() {
370        if ( ! Email_Log::has_v4_schema() ) {
371            return true;
372        }
373        $state = self::backfill_state();
374        return null !== $state && ! $state['done'];
375    }
376
377    /**
378     * Log rows not in the counts yet, grouped by hour, status, type and origin: every row before the
379     * upgrade, and the rows the backfill hasn't reached while it runs. So the numbers are right either way.
380     *
381     * @param int $start_utc First second, Unix time.
382     * @param int $end_utc   Second after the last one.
383     * @return array<int, array{bucket: int, status: string, source: string, origin: string, n: int}>
384     */
385    private static function uncounted( $start_utc, $end_utc ) {
386        if ( ! Email_Log::has_v4_schema() ) {
387            return self::count_log_rows( 0, PHP_INT_MAX, (int) $start_utc, (int) $end_utc );
388        }
389        $state = self::backfill_state();
390        if ( null === $state || $state['done'] ) {
391            return array();
392        }
393        return self::count_log_rows( $state['cursor'], $state['until_id'], (int) $start_utc, (int) $end_utc );
394    }
395
396    /**
397     * Count log rows with `$after_id < id <= $until_id` inside a window, in batches, grouping in PHP.
398     *
399     * @param int      $after_id  Lowest id, excluded.
400     * @param int      $until_id  Highest id, included.
401     * @param int|null $start_utc First second, or null for any.
402     * @param int|null $end_utc   Second after the last one, or null for any.
403     * @return array<int, array{bucket: int, status: string, source: string, origin: string, n: int}>
404     */
405    private static function count_log_rows( $after_id, $until_id, $start_utc = null, $end_utc = null ) {
406        global $wpdb;
407        if ( ! Email_Log::is_installed() ) {
408            return array();
409        }
410        $zone   = wp_timezone();
411        $from   = null === $start_utc ? '0000-00-00 00:00:00' : gmdate( 'Y-m-d H:i:s', $start_utc );
412        $to     = null === $end_utc ? '9999-12-31 23:59:59' : gmdate( 'Y-m-d H:i:s', $end_utc );
413        $counts = array();
414        $last   = (int) $after_id;
415        do {
416            $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, created_at, status, source, origin FROM %i WHERE id > %d AND id <= %d AND created_at >= %s AND created_at < %s ORDER BY id LIMIT %d', Email_Log::table_name(), $last, $until_id, $from, $to, self::BACKFILL_ROWS ), ARRAY_A );
417            foreach ( $rows as $row ) {
418                $bucket = self::bucket( (int) strtotime( $row['created_at'] . ' UTC' ), $zone );
419                $key    = $bucket . '|' . $row['status'] . '|' . $row['source'] . '|' . $row['origin'];
420                if ( ! isset( $counts[ $key ] ) ) {
421                    $counts[ $key ] = array(
422                        'bucket' => $bucket,
423                        'status' => (string) $row['status'],
424                        'source' => (string) $row['source'],
425                        'origin' => (string) $row['origin'],
426                        'n'      => 0,
427                    );
428                }
429                ++$counts[ $key ]['n'];
430                $last = max( $last, (int) $row['id'] );
431            }
432            $full = count( $rows ) === self::BACKFILL_ROWS;
433        } while ( $full );
434        return array_values( $counts );
435    }
436
437    /**
438     * The backfill's state.
439     *
440     * @since $$next-version$$
441     *
442     * @return array{until_id: int, cursor: int, done: bool}|null Null before the upgrade wrote it.
443     */
444    public static function backfill_state() {
445        $state = get_option( self::BACKFILL_OPTION, null );
446        if ( ! is_array( $state ) || ! isset( $state['until_id'] ) ) {
447            return null;
448        }
449        return array(
450            'until_id' => (int) $state['until_id'],
451            'cursor'   => (int) ( $state['cursor'] ?? 0 ),
452            'done'     => ! empty( $state['done'] ),
453        );
454    }
455
456    /**
457     * Write the backfill's boundary once: emails logged before the upgrade are counted by the backfill,
458     * later ones live. A boundary that's already there (a second upgrade after a rollback) stays.
459     *
460     * @since $$next-version$$
461     *
462     * @param int $until_id The newest log row at the upgrade; 0 for an empty log.
463     * @return void
464     */
465    public static function start_backfill( $until_id ) {
466        if ( false !== get_option( self::BACKFILL_OPTION, false ) ) {
467            return;
468        }
469        $until_id = max( 0, (int) $until_id );
470        update_option(
471            self::BACKFILL_OPTION,
472            array(
473                'until_id' => $until_id,
474                'cursor'   => 0,
475                'done'     => 0 === $until_id,
476            ),
477            false
478        );
479    }
480
481    /**
482     * Count the next batches of emails logged before the upgrade. Only adds; never deletes or lowers.
483     *
484     * @since $$next-version$$
485     *
486     * @param float $budget_seconds Keep going while there's time; 0 for one batch.
487     * @return bool Whether the backfill is done.
488     */
489    public static function backfill_batch( $budget_seconds = 2.0 ) {
490        global $wpdb;
491        $state = self::backfill_state();
492        if ( ! Email_Log::has_v4_schema() || null === $state ) {
493            return false;
494        }
495        if ( $state['done'] ) {
496            return true;
497        }
498        if ( ! Lock::acquire( 'backfill', 5 * MINUTE_IN_SECONDS ) ) {
499            return false;
500        }
501
502        try {
503            $started = microtime( true );
504            $zone    = wp_timezone();
505            do {
506                $rows  = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT id, created_at, status, source, origin FROM %i WHERE id > %d AND id <= %d ORDER BY id LIMIT %d', Email_Log::table_name(), $state['cursor'], $state['until_id'], self::BACKFILL_ROWS ), ARRAY_A );
507                $batch = array();
508                foreach ( $rows as $row ) {
509                    $time = (int) strtotime( $row['created_at'] . ' UTC' );
510                    $key  = self::bucket( $time, $zone ) . '|' . $row['status'] . '|' . $row['source'] . '|' . $row['origin'];
511                    if ( ! isset( $batch[ $key ] ) ) {
512                        $batch[ $key ] = array( $time, (string) $row['status'], (string) $row['source'], (string) $row['origin'], 0 );
513                    }
514                    ++$batch[ $key ][4];
515                }
516
517                $state['cursor'] = count( $rows ) < self::BACKFILL_ROWS ? $state['until_id'] : (int) end( $rows )['id'];
518                $state['done']   = $state['cursor'] >= $state['until_id'];
519                // The cursor moves before the counts are added: a request that dies here drops a count, never doubles one.
520                update_option( self::BACKFILL_OPTION, $state, false );
521                foreach ( $batch as $count ) {
522                    self::add_count( $count[0], $count[1], $count[2], $count[3], $count[4] );
523                }
524            } while ( ! $state['done'] && microtime( true ) - $started < $budget_seconds );
525        } finally {
526            Lock::release( 'backfill' );
527        }
528        return $state['done'];
529    }
530
531    /**
532     * Delete counts older than KEEP_DAYS.
533     *
534     * @since $$next-version$$
535     *
536     * @return void
537     */
538    public static function prune() {
539        global $wpdb;
540        if ( ! Email_Log::has_v4_schema() ) {
541            return;
542        }
543        $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE bucket < %d', self::table_name(), time() - self::KEEP_DAYS * DAY_IN_SECONDS ) );
544    }
545
546    /**
547     * Drop the table and the backfill's state (uninstall).
548     *
549     * @since $$next-version$$
550     *
551     * @return void
552     */
553    public static function drop() {
554        global $wpdb;
555        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', self::table_name() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Uninstall.
556        delete_option( self::BACKFILL_OPTION );
557    }
558}