From aaca248056670eb4d1cf04d1af9c79f7643348b2 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Wed, 14 Oct 2026 15:53:31 +0200 Subject: [PATCH 1/2] Pulse Monitor: Cost page views nothing unless visitor counting is on OpenStation adds nothing to front-end pages, and the monitor now keeps it that way on the server too. Front-end page views (visitors, bots) are only counted when an administrator turns on the new `pulse_visitors` extended option ("Count site visitors in the Pulse Monitor"), off by default; until then a page view returns before anything is classified or written, even with a monitor open. A front-end 5xx still beats. When a request is recorded, the write no longer delays anyone or weighs on the database: - The recorder runs last on `shutdown` and closes the response first (fastcgi_finish_request / litespeed_finish_request), filterable via `openstation_pulse_finish_request`. - With a persistent object cache the log lives in the cache and never touches `wp_options`. - Without one, ordinary traffic writes the log at most once a second (autoloaded `openstation_pulse_gate`); failures and explicit events (comments, logins, orders, publishing) always write. New filter `openstation_pulse_count_visitors` overrides the option. Co-Authored-By: Claude Opus 5.5 --- apps/os-settings/parts/features.ts | 5 + apps/os-settings/parts/types.ts | 1 + docs/data-model.md | 5 +- docs/hooks-reference.md | 9 ++ docs/pulse-monitor.md | 11 +- includes/extended-options.php | 8 +- includes/pulse-monitor.php | 177 ++++++++++++++++++--- src/plugins/pulse-monitor/config.ts | 24 ++- src/types.ts | 1 + tests/phpunit/tests/pulseMonitor.php | 73 ++++++++- tests/vitest/extended-options-save.test.ts | 10 +- tests/vitest/helpers/os-settings-app.ts | 2 +- 12 files changed, 291 insertions(+), 35 deletions(-) diff --git a/apps/os-settings/parts/features.ts b/apps/os-settings/parts/features.ts index 2cee12bb2..9ef75b509 100644 --- a/apps/os-settings/parts/features.ts +++ b/apps/os-settings/parts/features.ts @@ -431,6 +431,11 @@ const extendedSection: Section = ( _s, ctx ) => { __( 'Enable games' ), __( 'Adds a Games app for every user: built-in games, scoreboards, and player-to-player challenges. Off by default — while off, nothing game-related runs anywhere, on the server or in the browser. Saved scores are kept across a disable and reappear when re-enabled.' ), ) } + ${ toggle( + 'pulse_visitors', + __( 'Count site visitors in the Pulse Monitor' ), + __( 'Lets the Pulse Monitor beat for every page view on your site, from people and from bots. Off by default, because while a monitor is open each page view that is not served from a page cache costs the server a little extra work after the page is sent. Comments, logins, orders, REST and cron beat either way, and so do server errors.' ), + ) } ${ toggle( 'window_prewarm', __( 'Preload windows on hover' ), diff --git a/apps/os-settings/parts/types.ts b/apps/os-settings/parts/types.ts index ec2eba569..bec0b1707 100644 --- a/apps/os-settings/parts/types.ts +++ b/apps/os-settings/parts/types.ts @@ -33,6 +33,7 @@ export interface ExtendedOptions { games: boolean; agents: boolean; network: boolean; + pulse_visitors: boolean; } /** What `data()` returns — the server facts that can change mid-session. */ diff --git a/docs/data-model.md b/docs/data-model.md index 6bc62f417..e9010eb5e 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -289,7 +289,7 @@ profile screen). AI agents are ordinary `wp_users` rows flagged with | `desktop_mode_files_schema_version` | Files | Installed schema version; a mismatch triggers the lazy install. | | `desktop_mode_games_schema_version` | Games | Same, for the two Games tables. | | `desktop_mode_migration_version` | Migrations | Last data migration applied (`includes/migrations.php`). | -| `desktop_mode_extended_options` | Preferences | Site-wide extended options: Media Library enhancement, Games, AI agents, OpenStation Network, plus `window_prewarm` and `admin_asset_cache` (both default `true`; administrator opt-outs apply on shell reload). | +| `desktop_mode_extended_options` | Preferences | Site-wide extended options: Media Library enhancement, Games, AI agents, OpenStation Network, Pulse Monitor visitor counting (`pulse_visitors`, default `false`), plus `window_prewarm` and `admin_asset_cache` (both default `true`; administrator opt-outs apply on shell reload). | | `desktop_mode_desktop_themes` | Desktop themes | Themes uploaded as ZIPs and the active selection; their files go to `uploads/desktop-mode-themes/`. | | `desktop_mode_agents_defaults_seeded` | Agents | Flag: default agents already created. | | `desktop_mode_media_dims_backfilled` | Media | Flag: dimensions backfill done. | @@ -297,7 +297,8 @@ profile screen). AI agents are ordinary `wp_users` rows flagged with | `desktop_mode_terms_cache_version` | Content graph | Cache version for terms. | | `_desktop_mode_presence` | Presence | Snapshot of every user's presence (`autoload = no`). Heartbeat updates it with a write throttle; a daily cron prunes it. | | `_desktop_mode_content_changes_log` | Content changes | Recent content changes for the feed, capped at 100 entries (`autoload = no`). | -| `openstation_pulse_log` | Pulse Monitor | `{ seq, entries }`: the rolling log of site-wide requests relayed to listening monitors over Heartbeat, at most 200 rows and two minutes old (`autoload = no`). A row is `{ seq, ts, kind, status, error, user }`; no URL, IP or user agent. Written on `shutdown` only while someone listens. See [Pulse Monitor](pulse-monitor.md). | +| `openstation_pulse_log` | Pulse Monitor | `{ seq, entries }`: the rolling log of site-wide requests relayed to listening monitors over Heartbeat, at most 200 rows and two minutes old (`autoload = no`). A row is `{ seq, ts, kind, status, error, user }`; no URL, IP or user agent. Written on `shutdown` only while someone listens, at most once a second for ordinary traffic; with a persistent object cache the log lives in the cache (group `openstation_pulse`, key `log`) instead. See [Pulse Monitor](pulse-monitor.md). | +| `openstation_pulse_gate` | Pulse Monitor | Unix ms of the last log write (`autoload = yes`, so checking it is free). Spaces the log writes out to one a second when there is no persistent object cache. | | `openstation_pulse_listeners` | Pulse Monitor | Listener leases, user id → unix expiry (`autoload = yes`, read on every request's `shutdown`). A Heartbeat tick from a mounted monitor extends its user's lease for three minutes, rewriting it past half-life only. | | `_desktop_mode_recycle_bin_change_ts` | Recycle Bin | Timestamp of the last bin change, for the badge (`autoload = no`). | | `openstation_app_store` | App Framework | The `Store` contract with `site` scope. | diff --git a/docs/hooks-reference.md b/docs/hooks-reference.md index b177e51e7..fe1c42be0 100644 --- a/docs/hooks-reference.md +++ b/docs/hooks-reference.md @@ -1869,6 +1869,15 @@ apply_filters( 'openstation_pulse_request_kind', string $kind ); // Whether a user agent counts as a bot (bot beats draw smaller). apply_filters( 'openstation_pulse_is_bot', bool $is_bot, string $user_agent ); +// Whether front-end page views (visitors, bots) are counted. Defaults to the +// `pulse_visitors` extended option, which is off: page views cost nothing. +apply_filters( 'openstation_pulse_count_visitors', bool $enabled ); + +// Whether to close the response (fastcgi_finish_request / litespeed_finish_request) +// before the log is written. Default true; return false if something later on +// `shutdown` still sends output. The recorder runs at PHP_INT_MAX. +apply_filters( 'openstation_pulse_finish_request', bool $finish ); + // One event before it is logged: { kind, status, error, user }. Return false to drop it. apply_filters( 'openstation_pulse_event', array|false $row ); diff --git a/docs/pulse-monitor.md b/docs/pulse-monitor.md index 969c09fa5..fd10ba210 100644 --- a/docs/pulse-monitor.md +++ b/docs/pulse-monitor.md @@ -124,8 +124,15 @@ Everything listed was seen by this browser, about this browser's own traffic, an `includes/pulse-monitor.php` records requests the shell cannot see and relays them over Heartbeat. -- **Cost.** Nothing is recorded unless a shell is listening. A mounted wallpaper contributes `openstation_pulse` to every Heartbeat tick, and each tick refreshes a three-minute lease in the autoloaded `openstation_pulse_listeners` option. With no live lease, a request pays one in-memory option read at `shutdown`. While any lease is live, **every** recorded request (front-end page views by every visitor included) does one `update_option` on the non-autoloaded `openstation_pulse_log` (capped at 200 rows / 2 minutes). On a busy site, an admin who leaves the wallpaper on means one `wp_options` UPDATE per page view, all on the same row; return `''` from `openstation_pulse_request_kind` to keep a class of traffic off the monitor. A listener's own requests and WP-CLI commands are never recorded. -- **Best effort.** The log and the leases are read-modify-write options with no lock. Under concurrent traffic some rows overwrite each other and those beats are lost; a lost lease write is redone on the next tick past its half-life. Fine for a live trace; not an audit log. +- **Cost.** The monitor never adds anything to front-end pages, and by default it costs a page view nothing on the server either: + - **Nobody listening.** Nothing is recorded unless a shell is listening. A mounted monitor contributes `openstation_pulse` to every Heartbeat tick, and each tick refreshes a three-minute lease in the autoloaded `openstation_pulse_listeners` option. With no live lease, a request pays one in-memory option read at `shutdown`. + - **Page views are opt-in.** Front-end page views (`visitors`, `bots`) are only counted when an administrator turns on **Count site visitors in the Pulse Monitor** (Settings → Features → Extended options; the `pulse_visitors` extended option, filter `openstation_pulse_count_visitors`). Off by default: while it is off, a page view returns before anything is classified or written, even with a monitor open. A front-end server error (5xx) is recorded either way. REST, cron, admin traffic and explicit events (comments, logins, orders, publishing) are not page views and always beat. + - **Nobody waits for a write.** When a request is recorded, the recorder runs last on `shutdown` and closes the response first (`fastcgi_finish_request()`, or `litespeed_finish_request()`), so the visitor's page is complete before the log is touched. mod_php has neither; there the write still happens after the page has been sent, but before the connection closes. Filter `openstation_pulse_finish_request` returns false to keep the connection open. + - **The write is small.** With a persistent object cache the log lives in the cache (group `openstation_pulse`) and never touches the database. Without one, ordinary traffic writes the non-autoloaded `openstation_pulse_log` option (capped at 200 rows / 2 minutes) at most once a second, gated by the autoloaded `openstation_pulse_gate` timestamp; the page views in between are sampled out. Failures and explicit events always write. + - **Never recorded.** A listener's own requests (the shell already sees them) and WP-CLI commands. Return `''` from `openstation_pulse_request_kind` to keep any other class of traffic off the monitor. + + Measured on a test site with a monitor open: a page view took the same time (≈ 22 ms median over 100 requests) with no monitor, with the monitor and counting off, and with counting on (3–4 rows written per 100 page views). +- **Best effort.** The log and the leases are read-modify-write stores with no lock. Under concurrent traffic some rows overwrite each other and those beats are lost; a lost lease write is redone on the next tick past its half-life. Fine for a live trace; not an audit log. - **Classification.** Cron → `cron`. REST, XML-RPC, admin-ajax and admin pages → `team` when logged in, `api` when not. Front-end → `visitors`, or `bots` when the user agent looks automated. Explicit events replace the classification: a new comment, review, pingback or trackback → `comments` (other comment types, such as WooCommerce order notes, are bookkeeping and do not beat; `wp_insert_comment` also fires for imports, so an importer beats once per comment), a post going live → `publish`, `wp_login` → `logins`, `wp_login_failed` → `logins` (failed), `woocommerce_new_order` → `orders`. - **Privacy.** Rows carry a kind, a timestamp, an HTTP status, an error flag and a user id. The user id is used only to exclude a listener's own traffic and never leaves the server. No URLs, IPs, user agents or names are stored. - **Delivery.** The first tick returns only the cursor, so mounting does not replay history. Later ticks return the newer events plus a health snapshot (`{ siteHealth, requests, failures, cronLate }`). A cursor ahead of the log means the log was reset; that tick relays everything the log holds and brings the cursor back down. The client replays each batch at its real spacing, one tick late. While mounted, the wallpaper raises a slower Heartbeat interval to 15 s and restores it on unmount. diff --git a/includes/extended-options.php b/includes/extended-options.php index 144649168..944dc8218 100644 --- a/includes/extended-options.php +++ b/includes/extended-options.php @@ -39,6 +39,11 @@ * opt-IN. While off, `includes/network/bootstrap.php` skips every * module file (see `openstation_network_enabled()`), and a * multisite keeps the site switcher it has on its own. + * - pulse_visitors: when true, the Pulse Monitor counts front-end + * page views (visitors and bots) while a monitor is open. **Defaults + * to `false`** — opt-IN, because each counted page view costs the + * server a little work (see `includes/pulse-monitor.php`). While + * off, front-end page views pay nothing for the monitor. * * @package OpenStation */ @@ -63,7 +68,7 @@ /** * Returns the extended options with defaults filled in. * - * @return array{ media_library_enhanced: bool, games: bool, agents: bool, network: bool, window_prewarm: bool, admin_asset_cache: bool } + * @return array{ media_library_enhanced: bool, games: bool, agents: bool, network: bool, pulse_visitors: bool, window_prewarm: bool, admin_asset_cache: bool } */ function openstation_get_extended_options() { $defaults = array( @@ -73,6 +78,7 @@ function openstation_get_extended_options() { 'games' => false, 'agents' => false, 'network' => false, + 'pulse_visitors' => false, ); $raw = get_option( OPENSTATION_EXTENDED_OPTIONS_KEY, array() ); if ( ! is_array( $raw ) ) { diff --git a/includes/pulse-monitor.php b/includes/pulse-monitor.php index 89cfe855b..d505491e9 100644 --- a/includes/pulse-monitor.php +++ b/includes/pulse-monitor.php @@ -18,14 +18,19 @@ * to every Heartbeat tick, and each tick refreshes a short lease in the * autoloaded `openstation_pulse_listeners` option. With no live lease * a request pays one in-memory option read at `shutdown` and returns. - * With one, EVERY recorded request — front-end page views included — - * pays one `update_option` on a non-autoloaded log capped at - * {@see OPENSTATION_PULSE_LOG_MAX} rows: on a busy site, an admin who - * leaves the monitor on means one `wp_options` UPDATE per page view, - * all on the same row. The `openstation_pulse_request_kind` filter can - * return '' to keep a class of traffic (front-end views, say) off the - * monitor. A listener's own requests are never recorded — the shell - * already sees them — and neither are WP-CLI commands. + * Front-end page views — visitors and bots — are not counted unless an + * administrator turns on the `pulse_visitors` extended option ("Count + * site visitors"), so by default a listening monitor costs visitors + * nothing either. When a request is recorded, the response is closed + * first (`fastcgi_finish_request()` where the server has it) so nobody + * waits for the write, and the write itself is kept small: with a + * persistent object cache the log lives there and never touches the + * database; without one, ordinary traffic writes the log at most once + * per {@see OPENSTATION_PULSE_WRITE_INTERVAL_MS} — the beats in + * between are sampled out — while failures and explicit events + * (comments, logins, orders…) always write. A listener's own requests + * are never recorded — the shell already sees them — and neither are + * WP-CLI commands. * * Best effort. The log and the leases are read-modify-write options * with no lock, so concurrent requests can overwrite each other's @@ -64,6 +69,24 @@ */ const OPENSTATION_PULSE_LEASE_TTL = 180; +/** + * Autoloaded option holding when the log was last written, in unix ms. + * Without a persistent object cache it spaces the database writes out; + * it is autoloaded so checking it costs nothing. + */ +const OPENSTATION_PULSE_GATE_OPTION = 'openstation_pulse_gate'; + +/** + * Object cache group the log lives in when a persistent cache is in use. + */ +const OPENSTATION_PULSE_CACHE_GROUP = 'openstation_pulse'; + +/** + * Minimum milliseconds between two database writes of the log for + * ordinary traffic, when there is no persistent object cache. + */ +const OPENSTATION_PULSE_WRITE_INTERVAL_MS = 1000; + /** * Maximum retained log rows. */ @@ -103,6 +126,81 @@ function openstation_pulse_reset() { $state['events'] = array(); } +/** + * Whether front-end page views — visitors and bots — beat. + * + * Off by default: counting them gives every uncached page view some + * server work while a monitor is open. An administrator turns it on + * with the `pulse_visitors` extended option ("Count site visitors"). + * + * @return bool + */ +function openstation_pulse_counts_visitors() { + $enabled = false; + if ( function_exists( 'openstation_get_extended_options' ) ) { + $options = openstation_get_extended_options(); + $enabled = ! empty( $options['pulse_visitors'] ); + } + + /** + * Filters whether the Pulse Monitor counts front-end page views + * (visitors and bots). Defaults to the `pulse_visitors` extended + * option, which is off. + * + * @param bool $enabled Whether front-end page views are recorded. + */ + return (bool) apply_filters( 'openstation_pulse_count_visitors', $enabled ); +} + +/** + * Whether this request is a front-end page view: not cron, REST, + * XML-RPC, admin-ajax or an admin screen. + * + * @return bool + */ +function openstation_pulse_is_front_end_request() { + return ! ( + wp_doing_cron() + || wp_doing_ajax() + || is_admin() + || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) + ); +} + +/** + * Whether the log lives in a persistent object cache instead of + * `wp_options`. + * + * @return bool + */ +function openstation_pulse_uses_object_cache() { + return (bool) wp_using_ext_object_cache(); +} + +/** + * Sends the response and closes the connection before the log is + * written, so the visitor never waits for it. A no-op where the server + * API cannot do it (mod_php, CLI). + */ +function openstation_pulse_finish_response() { + /** + * Filters whether the Pulse Monitor closes the response before it + * writes the log. Return false if something later on `shutdown` + * still needs to send output. + * + * @param bool $finish Whether to finish the response early. + */ + if ( ! apply_filters( 'openstation_pulse_finish_request', true ) ) { + return; + } + if ( function_exists( 'fastcgi_finish_request' ) ) { + fastcgi_finish_request(); + } elseif ( function_exists( 'litespeed_finish_request' ) ) { + litespeed_finish_request(); + } +} + /** * Returns the live listener leases, pruned of expired entries. * @@ -247,7 +345,9 @@ function openstation_pulse_classify_request() { * } */ function openstation_pulse_read_log( $now ) { - $log = get_option( OPENSTATION_PULSE_LOG_OPTION, array() ); + $log = openstation_pulse_uses_object_cache() + ? wp_cache_get( 'log', OPENSTATION_PULSE_CACHE_GROUP ) + : get_option( OPENSTATION_PULSE_LOG_OPTION, array() ); $seq = ( is_array( $log ) && isset( $log['seq'] ) ) ? (int) $log['seq'] : 0; $entries = ( is_array( $log ) && isset( $log['entries'] ) && is_array( $log['entries'] ) ) ? $log['entries'] : array(); $cutoff = $now - OPENSTATION_PULSE_LOG_WINDOW_MS; @@ -291,14 +391,35 @@ function openstation_pulse_append( $rows, $now ) { $entries = array_slice( $entries, -OPENSTATION_PULSE_LOG_MAX ); } - update_option( - OPENSTATION_PULSE_LOG_OPTION, - array( - 'seq' => $seq, - 'entries' => $entries, - ), - false + $log = array( + 'seq' => $seq, + 'entries' => $entries, ); + if ( openstation_pulse_uses_object_cache() ) { + wp_cache_set( 'log', $log, OPENSTATION_PULSE_CACHE_GROUP ); + return; + } + update_option( OPENSTATION_PULSE_LOG_OPTION, $log, false ); +} + +/** + * Without a persistent object cache, whether ordinary traffic may write + * the log now — at most once per {@see OPENSTATION_PULSE_WRITE_INTERVAL_MS}. + * Claims the slot when it does. + * + * @param int $now Current time in milliseconds. + * @return bool + */ +function openstation_pulse_claim_write( $now ) { + if ( openstation_pulse_uses_object_cache() ) { + return true; + } + $last = (int) get_option( OPENSTATION_PULSE_GATE_OPTION, 0 ); + if ( $now - $last < OPENSTATION_PULSE_WRITE_INTERVAL_MS && $now >= $last ) { + return false; + } + update_option( OPENSTATION_PULSE_GATE_OPTION, $now, true ); + return true; } /** @@ -308,6 +429,7 @@ function openstation_pulse_on_shutdown() { $listeners = openstation_pulse_listeners(); $state = &openstation_pulse_state(); $explicit = $state['events']; + $announced = ! empty( $explicit ); openstation_pulse_reset(); // A WP-CLI command is not site traffic — and with no user agent it @@ -327,6 +449,11 @@ function openstation_pulse_on_shutdown() { $status = is_int( $status ) ? $status : 200; if ( empty( $explicit ) ) { + // Page views are off by default; a front-end server error still + // counts, it is what the monitor is for. + if ( $status < 500 && openstation_pulse_is_front_end_request() && ! openstation_pulse_counts_visitors() ) { + return; + } $kind = openstation_pulse_classify_request(); if ( '' === $kind ) { return; @@ -372,9 +499,19 @@ function openstation_pulse_on_shutdown() { } } - if ( ! empty( $rows ) ) { - openstation_pulse_append( $rows, (int) round( microtime( true ) * 1000 ) ); + if ( empty( $rows ) ) { + return; + } + + // Ordinary traffic is sampled; a failure or an explicit event (a + // comment, a login, an order) always makes it into the log. + $now = (int) round( microtime( true ) * 1000 ); + $always = $announced || in_array( true, wp_list_pluck( $rows, 'error' ), true ); + if ( ! $always && ! openstation_pulse_claim_write( $now ) ) { + return; } + openstation_pulse_finish_response(); + openstation_pulse_append( $rows, $now ); } /** @@ -623,7 +760,9 @@ function () { openstation_pulse_record( 'orders' ); } ); - add_action( 'shutdown', 'openstation_pulse_on_shutdown' ); + // Last on `shutdown`: the recorder may close the response before it + // writes, and nothing after it should still be sending output. + add_action( 'shutdown', 'openstation_pulse_on_shutdown', PHP_INT_MAX ); add_filter( 'heartbeat_received', 'openstation_pulse_heartbeat_received', 10, 2 ); } add_action( 'init', 'openstation_pulse_register_hooks', 5 ); diff --git a/src/plugins/pulse-monitor/config.ts b/src/plugins/pulse-monitor/config.ts index d86e533e5..21ad2fb26 100644 --- a/src/plugins/pulse-monitor/config.ts +++ b/src/plugins/pulse-monitor/config.ts @@ -134,6 +134,17 @@ function segmentedField( * doubles as a tuning panel. Rebuilt in place on reset. Returns its * teardown (listeners die with the DOM, so it has nothing to do). */ +/** + * Whether the site counts front-end page views — the `pulse_visitors` + * extended option. Only administrators get the options in the boot + * config; for anyone else the site-wide sources are not relayed anyway. + */ +function countsVisitors(): boolean { + const config = ( window as unknown as { openStationConfig?: { extendedOptions?: { pulse_visitors?: boolean } | null } } ) + .openStationConfig; + return config?.extendedOptions?.pulse_visitors === true; +} + export function renderPulseConfig( container: HTMLElement, ctx: PulseConfigContext ): () => void { let current = sanitizePulseSettings( ctx.settings ); @@ -228,10 +239,15 @@ export function renderPulseConfig( container: HTMLElement, ctx: PulseConfigConte add( heading( __( 'Listening to — this browser' ) ) ); add( ...sourceSwitches( 'client' ) ); add( heading( __( 'Listening to — the whole site' ) ) ); - add( - note( __( 'Relayed over Heartbeat, so they arrive in batches and replay at their real spacing. Administrators only.' ) ), - ...sourceSwitches( 'server' ), - ); + add( note( __( 'Relayed over Heartbeat, so they arrive in batches and replay at their real spacing. Administrators only.' ) ) ); + if ( ! countsVisitors() ) { + add( + note( + __( 'Page views from visitors and bots are not counted: off by default, so the monitor never slows your site down. An administrator can turn on “Count site visitors in the Pulse Monitor” in Settings → Features → Extended options.' ), + ), + ); + } + add( ...sourceSwitches( 'server' ) ); const reset = document.createElement( 'os-button' ); reset.setAttribute( 'variant', 'ghost' ); diff --git a/src/types.ts b/src/types.ts index c65ad45a1..9b7e58911 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2803,6 +2803,7 @@ export interface DesktopConfig { games: boolean; agents: boolean; network: boolean; + pulse_visitors: boolean; } | null; /** * REST endpoint for reading/writing extended options (admin only). diff --git a/tests/phpunit/tests/pulseMonitor.php b/tests/phpunit/tests/pulseMonitor.php index 6bb276742..670c13b2e 100644 --- a/tests/phpunit/tests/pulseMonitor.php +++ b/tests/phpunit/tests/pulseMonitor.php @@ -25,15 +25,23 @@ public function set_up() { openstation_pulse_reset(); delete_option( OPENSTATION_PULSE_LOG_OPTION ); delete_option( OPENSTATION_PULSE_LISTENERS_OPTION ); + delete_option( OPENSTATION_PULSE_GATE_OPTION ); delete_transient( 'health-check-site-status-result' ); wp_set_current_user( 0 ); + // Most tests are about what a page view records; the opt-in that + // lets page views through has its own tests below. + add_filter( 'openstation_pulse_count_visitors', '__return_true' ); } public function tear_down() { openstation_pulse_reset(); delete_option( OPENSTATION_PULSE_LOG_OPTION ); delete_option( OPENSTATION_PULSE_LISTENERS_OPTION ); + delete_option( OPENSTATION_PULSE_GATE_OPTION ); + delete_option( OPENSTATION_EXTENDED_OPTIONS_KEY ); delete_transient( 'health-check-site-status-result' ); + wp_cache_delete( 'log', OPENSTATION_PULSE_CACHE_GROUP ); + remove_all_filters( 'openstation_pulse_count_visitors' ); remove_all_filters( 'openstation_pulse_event' ); remove_all_filters( 'openstation_pulse_request_kind' ); remove_all_filters( 'openstation_pulse_capability' ); @@ -280,6 +288,69 @@ public function test_log_is_pruned_by_count_and_age() { $this->assertSame( OPENSTATION_PULSE_LOG_MAX + 26, $this->log_entries()[0]['seq'] ); } + /* --------------------------------------------------------------------- + * Front-end cost + * ------------------------------------------------------------------- */ + + /** + * @covers ::openstation_pulse_on_shutdown + * @covers ::openstation_pulse_counts_visitors + */ + public function test_page_views_are_not_counted_by_default() { + remove_all_filters( 'openstation_pulse_count_visitors' ); + $this->listen_as( self::$admin_id ); + $_SERVER['HTTP_USER_AGENT'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) Safari/605.1.15'; + openstation_pulse_on_shutdown(); + + $this->assertFalse( openstation_pulse_counts_visitors() ); + $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ), 'A page view writes nothing.' ); + $this->assertFalse( get_option( OPENSTATION_PULSE_GATE_OPTION ) ); + + openstation_save_extended_options( array( 'pulse_visitors' => true ) ); + openstation_pulse_on_shutdown(); + $this->assertSame( array( 'visitors' ), wp_list_pluck( $this->log_entries(), 'kind' ), 'The extended option turns them on.' ); + } + + /** + * @covers ::openstation_pulse_on_shutdown + * @covers ::openstation_pulse_claim_write + */ + public function test_ordinary_traffic_writes_at_most_once_per_interval() { + $this->listen_as( self::$admin_id ); + openstation_pulse_on_shutdown(); + openstation_pulse_on_shutdown(); + $this->assertCount( 1, $this->log_entries(), 'The second page view inside the interval is sampled out.' ); + + do_action( 'wp_login_failed', 'nobody', new WP_Error() ); + openstation_pulse_on_shutdown(); + $this->assertSame( array( 'bots', 'logins' ), wp_list_pluck( $this->log_entries(), 'kind' ), 'Explicit events always write.' ); + + update_option( OPENSTATION_PULSE_GATE_OPTION, (int) round( microtime( true ) * 1000 ) - OPENSTATION_PULSE_WRITE_INTERVAL_MS - 1 ); + openstation_pulse_on_shutdown(); + $this->assertCount( 3, $this->log_entries(), 'The next interval writes again.' ); + } + + /** + * @covers ::openstation_pulse_append + * @covers ::openstation_pulse_read_log + */ + public function test_object_cache_keeps_the_log_out_of_the_database() { + $was = wp_using_ext_object_cache( true ); + try { + $this->listen_as( self::$admin_id ); + openstation_pulse_on_shutdown(); + openstation_pulse_on_shutdown(); + + $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ) ); + $this->assertFalse( get_option( OPENSTATION_PULSE_GATE_OPTION ), 'No write gate: nothing to space out.' ); + $log = openstation_pulse_read_log( (int) round( microtime( true ) * 1000 ) ); + $this->assertSame( 2, $log['seq'] ); + $this->assertCount( 2, $log['entries'] ); + } finally { + wp_using_ext_object_cache( (bool) $was ); // Passing null would leave it on. + } + } + /* --------------------------------------------------------------------- * Heartbeat relay * ------------------------------------------------------------------- */ @@ -440,7 +511,7 @@ public function test_one_script_serves_both_faces() { $this->assertTrue( wp_script_is( 'os-pulse-monitor', 'registered' ) ); $this->assertContains( 'heartbeat', wp_scripts()->registered['os-pulse-monitor']->deps ); $this->assertSame( 10, has_filter( 'heartbeat_received', 'openstation_pulse_heartbeat_received' ) ); - $this->assertSame( 10, has_action( 'shutdown', 'openstation_pulse_on_shutdown' ) ); + $this->assertSame( PHP_INT_MAX, has_action( 'shutdown', 'openstation_pulse_on_shutdown' ), 'Last, so closing the response cuts nobody short.' ); } /** diff --git a/tests/vitest/extended-options-save.test.ts b/tests/vitest/extended-options-save.test.ts index 52269646d..930fc7506 100644 --- a/tests/vitest/extended-options-save.test.ts +++ b/tests/vitest/extended-options-save.test.ts @@ -92,7 +92,7 @@ describe( 'Extended Options — saving', () => { await flush(); expect( dispatch ).toHaveBeenCalledTimes( 1 ); expect( dispatch.mock.calls[ 0 ][ 0 ] ).toBe( 'extended' ); - expect( optionsOf( 0 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: true, agents: false, network: false } ); + expect( optionsOf( 0 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: true, agents: false, network: false, pulse_visitors: false } ); } ); test( 'a second toggle carries the newest values', async () => { @@ -101,7 +101,7 @@ describe( 'Extended Options — saving', () => { toggle( 'Enable AI agents', true ); await flush(); expect( dispatch ).toHaveBeenCalledTimes( 2 ); - expect( optionsOf( 1 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: true, agents: true, network: false } ); + expect( optionsOf( 1 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: true, agents: true, network: false, pulse_visitors: false } ); } ); test( 'rapid toggles while a save is in flight carry the newest values and do not drop prior toggles', async () => { @@ -123,8 +123,8 @@ describe( 'Extended Options — saving', () => { await new Promise( ( resolve ) => setTimeout( resolve, 150 ) ); expect( dispatch ).toHaveBeenCalledTimes( 2 ); - expect( optionsOf( 0 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: false, games: false, agents: false, network: false } ); - expect( optionsOf( 1 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: false, games: true, agents: false, network: false } ); + expect( optionsOf( 0 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: false, games: false, agents: false, network: false, pulse_visitors: false } ); + expect( optionsOf( 1 ) ).toEqual( { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: false, games: true, agents: false, network: false, pulse_visitors: false } ); expect( el.querySelector( '.os-ext__saving' ) ).toBeNull(); } ); @@ -135,7 +135,7 @@ describe( 'Extended Options — saving', () => { ); toggle( 'Enable games', true ); await flush(); - expect( heard ).toEqual( [ { options: { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: true, agents: false, network: false } } ] ); + expect( heard ).toEqual( [ { options: { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: true, agents: false, network: false, pulse_visitors: false } } ] ); } ); test( 'a failed save says so inline and announces nothing', async () => { diff --git a/tests/vitest/helpers/os-settings-app.ts b/tests/vitest/helpers/os-settings-app.ts index 9bbe19ad4..1809ece69 100644 --- a/tests/vitest/helpers/os-settings-app.ts +++ b/tests/vitest/helpers/os-settings-app.ts @@ -9,7 +9,7 @@ export function appData( overrides: Partial< AppData > = {} ): AppData { isAdmin: true, canUpload: true, canManageDesktopThemes: true, - extendedOptions: { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: false, agents: false, network: false }, + extendedOptions: { window_prewarm: true, admin_asset_cache: true, media_library_enhanced: true, games: false, agents: false, network: false, pulse_visitors: false }, aiAssistant: null, ...overrides, }; From 0676ab95266a718bc906a9a4cb0bad090e42ca14 Mon Sep 17 00:00:00 2001 From: prismiwi2015 Date: Wed, 14 Oct 2026 16:04:04 +0200 Subject: [PATCH 2/2] Pulse Monitor: Sample every write, keep the user's Heartbeat rate - Failures are sampled like everything else (one log write a second at most), so the site-wide failure rate stays fair and a burst such as a login attack never becomes a write per request. Only comments, publishing and orders always write. - With a persistent object cache the one-second gate is an atomic `wp_cache_add`, so the requests in between never fetch the log. - The monitor no longer forces Heartbeat to 15 s: it keeps the user's own rate, since every tick is a full request to the server. Co-Authored-By: Claude Opus 5.5 --- docs/data-model.md | 4 ++-- docs/pulse-monitor.md | 4 ++-- includes/pulse-monitor.php | 35 ++++++++++++++++------------ src/plugins/pulse-monitor/signals.ts | 21 ++++------------- tests/phpunit/tests/pulseMonitor.php | 13 +++++++---- 5 files changed, 38 insertions(+), 39 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index e9010eb5e..ed755813b 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -297,8 +297,8 @@ profile screen). AI agents are ordinary `wp_users` rows flagged with | `desktop_mode_terms_cache_version` | Content graph | Cache version for terms. | | `_desktop_mode_presence` | Presence | Snapshot of every user's presence (`autoload = no`). Heartbeat updates it with a write throttle; a daily cron prunes it. | | `_desktop_mode_content_changes_log` | Content changes | Recent content changes for the feed, capped at 100 entries (`autoload = no`). | -| `openstation_pulse_log` | Pulse Monitor | `{ seq, entries }`: the rolling log of site-wide requests relayed to listening monitors over Heartbeat, at most 200 rows and two minutes old (`autoload = no`). A row is `{ seq, ts, kind, status, error, user }`; no URL, IP or user agent. Written on `shutdown` only while someone listens, at most once a second for ordinary traffic; with a persistent object cache the log lives in the cache (group `openstation_pulse`, key `log`) instead. See [Pulse Monitor](pulse-monitor.md). | -| `openstation_pulse_gate` | Pulse Monitor | Unix ms of the last log write (`autoload = yes`, so checking it is free). Spaces the log writes out to one a second when there is no persistent object cache. | +| `openstation_pulse_log` | Pulse Monitor | `{ seq, entries }`: the rolling log of site-wide requests relayed to listening monitors over Heartbeat, at most 200 rows and two minutes old (`autoload = no`). A row is `{ seq, ts, kind, status, error, user }`; no URL, IP or user agent. Written on `shutdown` only while someone listens, at most once a second (comments, publishing and orders excepted); with a persistent object cache the log lives in the cache (group `openstation_pulse`, key `log`) instead. See [Pulse Monitor](pulse-monitor.md). | +| `openstation_pulse_gate` | Pulse Monitor | Unix ms of the last log write (`autoload = yes`, so checking it is free). Spaces the log writes out to one a second when there is no persistent object cache (with one, the gate is a one-second cache key in the same group). | | `openstation_pulse_listeners` | Pulse Monitor | Listener leases, user id → unix expiry (`autoload = yes`, read on every request's `shutdown`). A Heartbeat tick from a mounted monitor extends its user's lease for three minutes, rewriting it past half-life only. | | `_desktop_mode_recycle_bin_change_ts` | Recycle Bin | Timestamp of the last bin change, for the badge (`autoload = no`). | | `openstation_app_store` | App Framework | The `Store` contract with `site` scope. | diff --git a/docs/pulse-monitor.md b/docs/pulse-monitor.md index fd10ba210..f9f8a3dfd 100644 --- a/docs/pulse-monitor.md +++ b/docs/pulse-monitor.md @@ -128,14 +128,14 @@ Everything listed was seen by this browser, about this browser's own traffic, an - **Nobody listening.** Nothing is recorded unless a shell is listening. A mounted monitor contributes `openstation_pulse` to every Heartbeat tick, and each tick refreshes a three-minute lease in the autoloaded `openstation_pulse_listeners` option. With no live lease, a request pays one in-memory option read at `shutdown`. - **Page views are opt-in.** Front-end page views (`visitors`, `bots`) are only counted when an administrator turns on **Count site visitors in the Pulse Monitor** (Settings → Features → Extended options; the `pulse_visitors` extended option, filter `openstation_pulse_count_visitors`). Off by default: while it is off, a page view returns before anything is classified or written, even with a monitor open. A front-end server error (5xx) is recorded either way. REST, cron, admin traffic and explicit events (comments, logins, orders, publishing) are not page views and always beat. - **Nobody waits for a write.** When a request is recorded, the recorder runs last on `shutdown` and closes the response first (`fastcgi_finish_request()`, or `litespeed_finish_request()`), so the visitor's page is complete before the log is touched. mod_php has neither; there the write still happens after the page has been sent, but before the connection closes. Filter `openstation_pulse_finish_request` returns false to keep the connection open. - - **The write is small.** With a persistent object cache the log lives in the cache (group `openstation_pulse`) and never touches the database. Without one, ordinary traffic writes the non-autoloaded `openstation_pulse_log` option (capped at 200 rows / 2 minutes) at most once a second, gated by the autoloaded `openstation_pulse_gate` timestamp; the page views in between are sampled out. Failures and explicit events always write. + - **Writes are rare and small.** At most one request a second writes the log; the ones in between are sampled out. Failures are sampled like everything else, so the site-wide failure rate the trace diagnoses from stays fair and a burst (a login attack, a crawler) never turns into a write per request. Only comments, publishing and orders always write: they are rare and already wrote to the database. With a persistent object cache, the log and its one-second gate (an atomic `wp_cache_add`) live in the cache (group `openstation_pulse`) and never touch the database. Without one, the log is the non-autoloaded `openstation_pulse_log` option (capped at 200 rows / 2 minutes), gated by the autoloaded `openstation_pulse_gate` timestamp, which costs nothing to check. - **Never recorded.** A listener's own requests (the shell already sees them) and WP-CLI commands. Return `''` from `openstation_pulse_request_kind` to keep any other class of traffic off the monitor. Measured on a test site with a monitor open: a page view took the same time (≈ 22 ms median over 100 requests) with no monitor, with the monitor and counting off, and with counting on (3–4 rows written per 100 page views). - **Best effort.** The log and the leases are read-modify-write stores with no lock. Under concurrent traffic some rows overwrite each other and those beats are lost; a lost lease write is redone on the next tick past its half-life. Fine for a live trace; not an audit log. - **Classification.** Cron → `cron`. REST, XML-RPC, admin-ajax and admin pages → `team` when logged in, `api` when not. Front-end → `visitors`, or `bots` when the user agent looks automated. Explicit events replace the classification: a new comment, review, pingback or trackback → `comments` (other comment types, such as WooCommerce order notes, are bookkeeping and do not beat; `wp_insert_comment` also fires for imports, so an importer beats once per comment), a post going live → `publish`, `wp_login` → `logins`, `wp_login_failed` → `logins` (failed), `woocommerce_new_order` → `orders`. - **Privacy.** Rows carry a kind, a timestamp, an HTTP status, an error flag and a user id. The user id is used only to exclude a listener's own traffic and never leaves the server. No URLs, IPs, user agents or names are stored. -- **Delivery.** The first tick returns only the cursor, so mounting does not replay history. Later ticks return the newer events plus a health snapshot (`{ siteHealth, requests, failures, cronLate }`). A cursor ahead of the log means the log was reset; that tick relays everything the log holds and brings the cursor back down. The client replays each batch at its real spacing, one tick late. While mounted, the wallpaper raises a slower Heartbeat interval to 15 s and restores it on unmount. +- **Delivery.** The first tick returns only the cursor, so mounting does not replay history. Later ticks return the newer events plus a health snapshot (`{ siteHealth, requests, failures, cronLate }`). A cursor ahead of the log means the log was reset; that tick relays everything the log holds and brings the cursor back down. The client replays each batch at its real spacing, one tick late. The monitor keeps the user's own Heartbeat rate (OpenStation settings, 15–60 s) instead of speeding it up, since every tick is a full request to the server; a slower rate only means site-wide beats arrive later. - **Access.** The feed requires `manage_options` by default (filter: `openstation_pulse_capability`). Users without it still get a live trace from their own shell. ### PHP API diff --git a/includes/pulse-monitor.php b/includes/pulse-monitor.php index d505491e9..385611833 100644 --- a/includes/pulse-monitor.php +++ b/includes/pulse-monitor.php @@ -23,12 +23,14 @@ * site visitors"), so by default a listening monitor costs visitors * nothing either. When a request is recorded, the response is closed * first (`fastcgi_finish_request()` where the server has it) so nobody - * waits for the write, and the write itself is kept small: with a - * persistent object cache the log lives there and never touches the - * database; without one, ordinary traffic writes the log at most once - * per {@see OPENSTATION_PULSE_WRITE_INTERVAL_MS} — the beats in - * between are sampled out — while failures and explicit events - * (comments, logins, orders…) always write. A listener's own requests + * waits for the write, and the write itself is kept rare and small: + * at most one request per {@see OPENSTATION_PULSE_WRITE_INTERVAL_MS} + * writes the log — the ones in between are sampled out, failures + * included, so the failure rate the trace diagnoses from stays fair and + * a burst (a login attack, a crawler) cannot turn into a write per + * request. Only comments, publishing and orders always write: they are + * rare and already wrote to the database. With a persistent object + * cache the log lives there and never touches the database. A listener's own requests * are never recorded — the shell already sees them — and neither are * WP-CLI commands. * @@ -82,8 +84,8 @@ const OPENSTATION_PULSE_CACHE_GROUP = 'openstation_pulse'; /** - * Minimum milliseconds between two database writes of the log for - * ordinary traffic, when there is no persistent object cache. + * Minimum milliseconds between two writes of the log, for everything + * but comments, publishing and orders. */ const OPENSTATION_PULSE_WRITE_INTERVAL_MS = 1000; @@ -403,16 +405,17 @@ function openstation_pulse_append( $rows, $now ) { } /** - * Without a persistent object cache, whether ordinary traffic may write - * the log now — at most once per {@see OPENSTATION_PULSE_WRITE_INTERVAL_MS}. - * Claims the slot when it does. + * Whether this request may write the log now — at most one request per + * {@see OPENSTATION_PULSE_WRITE_INTERVAL_MS}. Claims the slot when it does. * * @param int $now Current time in milliseconds. * @return bool */ function openstation_pulse_claim_write( $now ) { if ( openstation_pulse_uses_object_cache() ) { - return true; + // `add` is atomic in Redis and Memcached: one request per second + // wins, and the rest never fetch the log. + return (bool) wp_cache_add( 'gate', $now, OPENSTATION_PULSE_CACHE_GROUP, (int) ceil( OPENSTATION_PULSE_WRITE_INTERVAL_MS / 1000 ) ); } $last = (int) get_option( OPENSTATION_PULSE_GATE_OPTION, 0 ); if ( $now - $last < OPENSTATION_PULSE_WRITE_INTERVAL_MS && $now >= $last ) { @@ -503,10 +506,12 @@ function openstation_pulse_on_shutdown() { return; } - // Ordinary traffic is sampled; a failure or an explicit event (a - // comment, a login, an order) always makes it into the log. + // Everything is sampled alike, failures included, so the failure + // rate stays fair and a burst cannot become a write per request. + // Comments, publishing and orders are rare and already wrote to the + // database: they always make it into the log. $now = (int) round( microtime( true ) * 1000 ); - $always = $announced || in_array( true, wp_list_pluck( $rows, 'error' ), true ); + $always = $announced && ! in_array( true, wp_list_pluck( $rows, 'error' ), true ); if ( ! $always && ! openstation_pulse_claim_write( $now ) ) { return; } diff --git a/src/plugins/pulse-monitor/signals.ts b/src/plugins/pulse-monitor/signals.ts index eca095b99..8c14a75e1 100644 --- a/src/plugins/pulse-monitor/signals.ts +++ b/src/plugins/pulse-monitor/signals.ts @@ -115,7 +115,6 @@ interface JQueryish { } interface WpHeartbeat { - interval( next?: number | string ): number; connectNow?: () => void; } @@ -706,21 +705,11 @@ export function wireSignals( opts: SignalOptions ): () => void { } ), ); - // Server events arrive one tick late, so a faster tick is a - // livelier trace. Raise a slow Heartbeat to 15 s while mounted and - // put it back afterwards. - if ( wpHeartbeat && typeof wpHeartbeat.interval === 'function' ) { - const previous = wpHeartbeat.interval(); - if ( previous > 15 ) { - wpHeartbeat.interval( 15 ); - disposers.push( () => { - if ( wpHeartbeat.interval() === 15 ) { - wpHeartbeat.interval( previous ); - } - } ); - } - wpHeartbeat.connectNow?.(); - } + // Server events arrive one tick late. The monitor keeps the user's + // own Heartbeat rate (Settings → Heartbeat rate) rather than speeding + // it up: every tick is a full request to the server. One tick now + // fetches the cursor, so the first batch is not a whole interval away. + wpHeartbeat?.connectNow?.(); return (): void => { for ( const dispose of disposers.splice( 0 ) ) { diff --git a/tests/phpunit/tests/pulseMonitor.php b/tests/phpunit/tests/pulseMonitor.php index 670c13b2e..309e74b1c 100644 --- a/tests/phpunit/tests/pulseMonitor.php +++ b/tests/phpunit/tests/pulseMonitor.php @@ -41,6 +41,7 @@ public function tear_down() { delete_option( OPENSTATION_EXTENDED_OPTIONS_KEY ); delete_transient( 'health-check-site-status-result' ); wp_cache_delete( 'log', OPENSTATION_PULSE_CACHE_GROUP ); + wp_cache_delete( 'gate', OPENSTATION_PULSE_CACHE_GROUP ); remove_all_filters( 'openstation_pulse_count_visitors' ); remove_all_filters( 'openstation_pulse_event' ); remove_all_filters( 'openstation_pulse_request_kind' ); @@ -323,7 +324,11 @@ public function test_ordinary_traffic_writes_at_most_once_per_interval() { do_action( 'wp_login_failed', 'nobody', new WP_Error() ); openstation_pulse_on_shutdown(); - $this->assertSame( array( 'bots', 'logins' ), wp_list_pluck( $this->log_entries(), 'kind' ), 'Explicit events always write.' ); + $this->assertCount( 1, $this->log_entries(), 'Failures are sampled too: a login attack is not a write per attempt.' ); + + openstation_pulse_record( 'comments' ); + openstation_pulse_on_shutdown(); + $this->assertSame( array( 'bots', 'comments' ), wp_list_pluck( $this->log_entries(), 'kind' ), 'A comment always writes.' ); update_option( OPENSTATION_PULSE_GATE_OPTION, (int) round( microtime( true ) * 1000 ) - OPENSTATION_PULSE_WRITE_INTERVAL_MS - 1 ); openstation_pulse_on_shutdown(); @@ -342,10 +347,10 @@ public function test_object_cache_keeps_the_log_out_of_the_database() { openstation_pulse_on_shutdown(); $this->assertFalse( get_option( OPENSTATION_PULSE_LOG_OPTION ) ); - $this->assertFalse( get_option( OPENSTATION_PULSE_GATE_OPTION ), 'No write gate: nothing to space out.' ); + $this->assertFalse( get_option( OPENSTATION_PULSE_GATE_OPTION ), 'The gate lives in the cache too.' ); $log = openstation_pulse_read_log( (int) round( microtime( true ) * 1000 ) ); - $this->assertSame( 2, $log['seq'] ); - $this->assertCount( 2, $log['entries'] ); + $this->assertSame( 1, $log['seq'], 'The cache gate samples the second page view out.' ); + $this->assertCount( 1, $log['entries'] ); } finally { wp_using_ext_object_cache( (bool) $was ); // Passing null would leave it on. }