Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/os-settings/parts/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down
1 change: 1 addition & 0 deletions apps/os-settings/parts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
5 changes: 3 additions & 2 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,16 @@ 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. |
| `desktop_mode_notes_rev` | Notes | Global notes revision; invalidates the per-user cache. |
| `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 (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. |
Expand Down
9 changes: 9 additions & 0 deletions docs/hooks-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand Down
13 changes: 10 additions & 3 deletions docs/pulse-monitor.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,18 @@ 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.
- **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
Expand Down
8 changes: 7 additions & 1 deletion includes/extended-options.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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(
Expand All @@ -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 ) ) {
Expand Down
Loading
Loading