Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.35% covered (warning)
63.35%
102 / 161
50.00% covered (danger)
50.00%
7 / 14
CRAP
n/a
0 / 0
wpcom_layout_grid_usage_should_load
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
wpcom_layout_grid_usage_react_to_post_insert
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
11.09
wpcom_layout_grid_usage_should_log_in_context
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_layout_grid_usage_mark_context_seen
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
wpcom_layout_grid_usage_context_transient_key
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
wpcom_layout_grid_usage_classify_origin
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
37.18
wpcom_layout_grid_usage_react_to_widget_block_added
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
wpcom_layout_grid_usage_react_to_widget_block_updated
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
wpcom_layout_grid_usage_react_to_block_render
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
3.01
wpcom_layout_grid_usage_widget_value_contains_block
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
wpcom_layout_grid_usage_log_observation
6.45% covered (danger)
6.45%
2 / 31
0.00% covered (danger)
0.00%
0 / 1
199.20
wpcom_layout_grid_usage_attribute_source
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
wpcom_layout_grid_usage_format_attribution_frame
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
wpcom_layout_grid_usage_redact_paths
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
9.05
1<?php
2/**
3 * Layout-grid usage tracking — emit a logstash event when a `jetpack/layout-grid`
4 * block is inserted or first rendered on a WoA site. The payload carries the
5 * active theme, active plugins, request-context flags, an `origin` label that
6 * separates an explicit editor insert (the expected, noisy case) from the
7 * arrivals we actually want to surface — migration, import, XML-RPC, WP-CLI,
8 * cron, a headless REST or AJAX write, a programmatic write with no user behind
9 * it, or a theme/template render — and a sanitized backtrace so the source can
10 * be attributed to a responsible plugin or theme rather than just the candidate
11 * set. Events ship to the `atomic_layout_grid_block` logstash bucket.
12 *
13 * @package automattic/jetpack-mu-wpcom
14 */
15
16declare(strict_types=1);
17
18const WPCOM_LAYOUT_GRID_USAGE_BLOCK_NAME       = 'jetpack/layout-grid';
19const WPCOM_LAYOUT_GRID_USAGE_SEEN_OPTION      = 'wpcom_layout_grid_block_seen';
20const WPCOM_LAYOUT_GRID_USAGE_IMPORT_TRANSIENT = 'wpcom_layout_grid_block_import_seen';
21const WPCOM_LAYOUT_GRID_USAGE_CRON_TRANSIENT   = 'wpcom_layout_grid_block_cron_seen';
22const WPCOM_LAYOUT_GRID_USAGE_LOG_FEATURE      = 'atomic_layout_grid_block';
23const WPCOM_LAYOUT_GRID_USAGE_LOG_MESSAGE      = 'layout_grid_block_observed';
24
25/**
26 * WoA-only gate. Extracted so tests can require the file without registering hooks.
27 *
28 * @return bool
29 */
30function wpcom_layout_grid_usage_should_load() {
31    if ( ! class_exists( '\Automattic\Jetpack\Status\Host' ) ) {
32        return false;
33    }
34    return ( new \Automattic\Jetpack\Status\Host() )->is_woa_site();
35}
36
37if ( wpcom_layout_grid_usage_should_load() ) {
38    add_action( 'wp_after_insert_post', 'wpcom_layout_grid_usage_react_to_post_insert', 10, 4 );
39    add_action( 'add_option_widget_block', 'wpcom_layout_grid_usage_react_to_widget_block_added', 10, 2 );
40    add_action( 'update_option_widget_block', 'wpcom_layout_grid_usage_react_to_widget_block_updated', 10, 2 );
41    add_filter( 'render_block_' . WPCOM_LAYOUT_GRID_USAGE_BLOCK_NAME, 'wpcom_layout_grid_usage_react_to_block_render', 10, 2 );
42}
43
44/**
45 * `wp_after_insert_post` handler. Editor first-landings log per-event; import
46 * and cron contexts are rate-limited to one event per blog per 24h.
47 *
48 * @param int           $post_id     Post ID.
49 * @param mixed         $post        Typed as WP_Post by core; checked defensively.
50 * @param bool          $update      Whether this is an update.
51 * @param \WP_Post|null $post_before Previous post version (null on insert).
52 * @return void
53 */
54function wpcom_layout_grid_usage_react_to_post_insert( $post_id, $post, $update, $post_before ) {
55    unset( $update );
56    // Revisions/autosaves are inserted as their own posts, so the
57    // `$post_before` check below can't see the parent's prior state.
58    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
59        return;
60    }
61    if ( ! $post instanceof \WP_Post ) {
62        return;
63    }
64    // Scan the raw content string so `has_block` doesn't route through
65    // `get_post()` and re-fetch a potentially different cache snapshot.
66    if ( ! has_block( WPCOM_LAYOUT_GRID_USAGE_BLOCK_NAME, (string) $post->post_content ) ) {
67        return;
68    }
69    if ( $post_before instanceof \WP_Post && has_block( WPCOM_LAYOUT_GRID_USAGE_BLOCK_NAME, (string) $post_before->post_content ) ) {
70        return;
71    }
72    $is_importing = defined( 'WP_IMPORTING' ) && WP_IMPORTING;
73    $is_cron      = defined( 'DOING_CRON' ) && DOING_CRON;
74    if ( ! wpcom_layout_grid_usage_should_log_in_context( $is_importing, $is_cron ) ) {
75        return;
76    }
77    $dispatched = wpcom_layout_grid_usage_log_observation(
78        array(
79            'surface'   => 'post_insert',
80            'post_type' => (string) $post->post_type,
81            'origin'    => wpcom_layout_grid_usage_classify_origin(),
82        )
83    );
84    // Burn the 24h budget only after a successful dispatch — otherwise a
85    // filter-blocked observation consumes the window with nothing logged.
86    if ( $dispatched ) {
87        wpcom_layout_grid_usage_mark_context_seen( $is_importing, $is_cron );
88    }
89}
90
91/**
92 * Read-only rate-limit gate. Returns false when the active context's transient
93 * is already set. Paired with `wpcom_layout_grid_usage_mark_context_seen()`
94 * post-dispatch so a logging failure doesn't burn the 24h budget. Import wins
95 * when both flags are set (cron-triggered import).
96 *
97 * @param bool $is_importing WP_IMPORTING flag.
98 * @param bool $is_cron      DOING_CRON flag.
99 * @return bool True if the caller should log.
100 */
101function wpcom_layout_grid_usage_should_log_in_context( $is_importing, $is_cron ) {
102    $key = wpcom_layout_grid_usage_context_transient_key( $is_importing, $is_cron );
103    if ( null === $key ) {
104        return true;
105    }
106    return ! get_transient( $key );
107}
108
109/**
110 * Write half of the rate-limit gate. No-op outside import / cron.
111 *
112 * @param bool $is_importing WP_IMPORTING flag.
113 * @param bool $is_cron      DOING_CRON flag.
114 * @return void
115 */
116function wpcom_layout_grid_usage_mark_context_seen( $is_importing, $is_cron ) {
117    $key = wpcom_layout_grid_usage_context_transient_key( $is_importing, $is_cron );
118    if ( null === $key ) {
119        return;
120    }
121    set_transient( $key, 1, DAY_IN_SECONDS );
122}
123
124/**
125 * Active rate-limit context's transient key, or null. Import wins.
126 *
127 * @param bool $is_importing WP_IMPORTING flag.
128 * @param bool $is_cron      DOING_CRON flag.
129 * @return string|null
130 */
131function wpcom_layout_grid_usage_context_transient_key( $is_importing, $is_cron ) {
132    if ( $is_importing ) {
133        return WPCOM_LAYOUT_GRID_USAGE_IMPORT_TRANSIENT;
134    }
135    if ( $is_cron ) {
136        return WPCOM_LAYOUT_GRID_USAGE_CRON_TRANSIENT;
137    }
138    return null;
139}
140
141/**
142 * Classify how an inserted block arrived, so the expected and noisy case — a
143 * logged-in user adding the block in the editor — can be told apart from the
144 * arrivals worth investigating. Checked in priority order:
145 *
146 *  - `migration`:    the WoA transfer is mid-flight — the block came over with
147 *                    the site. Checked before `import` because migration runs
148 *                    also set `WP_IMPORTING`, and "arrived during transfer" is
149 *                    the more specific (and, post-conditional-activation, the
150 *                    more interesting) answer.
151 *  - `import`:       a `WP_IMPORTING` run (an importer plugin, a WXR import).
152 *  - `xmlrpc`:       a remote publishing client (legacy apps, some migration
153 *                    tools) writing over XML-RPC.
154 *  - `cli`:          a WP-CLI invocation.
155 *  - `cron`:         a scheduled / background job.
156 *  - `editor`:       a logged-in user on an ordinary request — the expected case.
157 *  - `rest`:         a REST write with no user behind it — a headless client or
158 *                    server-to-server integration rather than the block editor.
159 *  - `ajax`:         an admin-ajax write with no user — a front-end handler.
160 *  - `programmatic`: none of the above — internal PHP wrote the post on a normal
161 *                    request with no HTTP API surface and no user.
162 *
163 * The batch / transport contexts win over the user check on purpose: an import,
164 * cron, or CLI run isn't a person at the editor even when a user happens to be
165 * set. The `editor` check sits above `rest`/`ajax` so a normal block-editor save
166 * (REST + a logged-in user) reads as `editor`, leaving those two buckets for the
167 * no-user writes they're meant to catch. The remaining false negative — a plugin
168 * calling `wp_insert_post()` on a normal admin request with a user logged in —
169 * reads as `editor`; the `trace` field is the backstop for naming that source.
170 * Used for the insert surfaces; the render backstop sets its own `origin` since
171 * by render time the context is gone.
172 *
173 * @return string One of: migration, import, xmlrpc, cli, cron, editor, rest, ajax, programmatic.
174 */
175function wpcom_layout_grid_usage_classify_origin() {
176    // @phan-suppress-next-line PhanUndeclaredFunction -- wpcomsh-provided; present on WoA, guarded by function_exists.
177    if ( function_exists( 'wpcomsh_is_migration_in_progress' ) && wpcomsh_is_migration_in_progress() ) {
178        return 'migration';
179    }
180    if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
181        return 'import';
182    }
183    if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
184        return 'xmlrpc';
185    }
186    if ( defined( 'WP_CLI' ) && WP_CLI ) {
187        return 'cli';
188    }
189    if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
190        return 'cron';
191    }
192    if ( function_exists( 'get_current_user_id' ) && get_current_user_id() > 0 ) {
193        return 'editor';
194    }
195    if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
196        return 'rest';
197    }
198    if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
199        return 'ajax';
200    }
201    return 'programmatic';
202}
203
204/**
205 * `add_option_widget_block` handler. Logs when the initial value carries the block.
206 *
207 * @param string $option Unused — pinned by hook name.
208 * @param mixed  $value  New option value.
209 * @return void
210 */
211function wpcom_layout_grid_usage_react_to_widget_block_added( $option, $value ) {
212    unset( $option );
213    if ( ! wpcom_layout_grid_usage_widget_value_contains_block( $value ) ) {
214        return;
215    }
216    wpcom_layout_grid_usage_log_observation(
217        array(
218            'surface' => 'widget_add',
219            'origin'  => wpcom_layout_grid_usage_classify_origin(),
220        )
221    );
222}
223
224/**
225 * `update_option_widget_block` handler. Logs first-landings only (new has, old didn't).
226 *
227 * @param mixed $old_value Previous option value.
228 * @param mixed $value     New option value.
229 * @return void
230 */
231function wpcom_layout_grid_usage_react_to_widget_block_updated( $old_value, $value ) {
232    if ( ! wpcom_layout_grid_usage_widget_value_contains_block( $value ) ) {
233        return;
234    }
235    if ( wpcom_layout_grid_usage_widget_value_contains_block( $old_value ) ) {
236        return;
237    }
238    wpcom_layout_grid_usage_log_observation(
239        array(
240            'surface' => 'widget_update',
241            'origin'  => wpcom_layout_grid_usage_classify_origin(),
242        )
243    );
244}
245
246/**
247 * Render-time backstop. Sentinel-guarded to fire at most once per blog — by
248 * render time the stack no longer reaches the cause, so per-pageview repeats
249 * add zero attribution value at non-trivial cost. One log per blog still tells
250 * us the block came from a theme template, pattern, or direct `$wpdb` write
251 * that the post/widget detectors didn't see.
252 *
253 * @param string $block_content Rendered block HTML.
254 * @param array  $parsed_block  Unused.
255 * @return string Unchanged.
256 */
257function wpcom_layout_grid_usage_react_to_block_render( $block_content, $parsed_block ) {
258    unset( $parsed_block );
259    if ( get_option( WPCOM_LAYOUT_GRID_USAGE_SEEN_OPTION ) ) {
260        return $block_content;
261    }
262    // Persist the sentinel only after a successful dispatch. The option has no
263    // TTL, so a filter-blocked observation that wrote it first would
264    // permanently disable the backstop for this blog.
265    if ( wpcom_layout_grid_usage_log_observation(
266        array(
267            'surface' => 'render',
268            // The block was only ever seen at render, never at insert — it came
269            // from a theme template, a pattern, or a direct write, not a user
270            // adding it in the editor.
271            'origin'  => 'render',
272        )
273    ) ) {
274        update_option( WPCOM_LAYOUT_GRID_USAGE_SEEN_OPTION, 1, false );
275    }
276    return $block_content;
277}
278
279/**
280 * Whether a `widget_block` option value carries the layout-grid block in any
281 * widget entry's `content`.
282 *
283 * @param mixed $value Option value.
284 * @return bool
285 */
286function wpcom_layout_grid_usage_widget_value_contains_block( $value ) {
287    if ( ! is_array( $value ) ) {
288        return false;
289    }
290    foreach ( $value as $widget ) {
291        if (
292            is_array( $widget )
293            && isset( $widget['content'] )
294            && is_string( $widget['content'] )
295            && has_block( WPCOM_LAYOUT_GRID_USAGE_BLOCK_NAME, $widget['content'] )
296        ) {
297            return true;
298        }
299    }
300    return false;
301}
302
303/**
304 * Dispatch an observation to logstash. Returns true if dispatch was attempted
305 * (filter passed and the wrapper class is loaded), false when short-circuited.
306 * Callers use the return value to gate sticky side effects (sentinel, context
307 * transients) so a blocked dispatch doesn't lock them out. Anything throwing
308 * during payload assembly (third-party filter callbacks, option lookups, etc.)
309 * or inside `Jetpack_Mu_Wpcom::log2logstash()` is caught and reported as a
310 * non-dispatch — telemetry must never escalate into a fatal on the host
311 * action chain (post save, front-end render).
312 *
313 * @param array $extra Caller-supplied payload. Caller keys win on collision.
314 * @return bool
315 */
316function wpcom_layout_grid_usage_log_observation( array $extra ) {
317    /**
318     * Whether layout-grid usage observations should be dispatched. Tests
319     * short-circuit this to keep `log2logstash` (and its HTTP fallback) out
320     * of the unit-test environment; sites that don't want telemetry can
321     * disable it the same way.
322     *
323     * @param bool  $enabled
324     * @param array $extra
325     */
326    if ( ! (bool) apply_filters( 'wpcom_layout_grid_usage_log_enabled', true, $extra ) ) {
327        return false;
328    }
329    if ( ! class_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom' ) ) {
330        return false;
331    }
332
333    try {
334        $active_plugins_raw = get_option( 'active_plugins' );
335        $active_plugins     = is_array( $active_plugins_raw ) ? array_values( $active_plugins_raw ) : array();
336        // Union network-activated plugins: WoA is multisite-shaped and the
337        // platform's plugins live in `active_sitewide_plugins`.
338        if ( function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'get_site_option' ) ) {
339            $sitewide_raw = get_site_option( 'active_sitewide_plugins' );
340            if ( is_array( $sitewide_raw ) && ! empty( $sitewide_raw ) ) {
341                $active_plugins = array_values( array_unique( array_merge( $active_plugins, array_keys( $sitewide_raw ) ) ) );
342            }
343        }
344
345        // `array_merge( defaults, $extra )`: caller keys win on collision.
346        $payload = array_merge(
347            array(
348                'active_theme'   => function_exists( 'get_stylesheet' ) ? (string) get_stylesheet() : '',
349                'active_plugins' => $active_plugins,
350                'is_rest'        => defined( 'REST_REQUEST' ) && REST_REQUEST,
351                'is_cli'         => defined( 'WP_CLI' ) && WP_CLI,
352                'is_cron'        => defined( 'DOING_CRON' ) && DOING_CRON,
353                'is_importing'   => defined( 'WP_IMPORTING' ) && WP_IMPORTING,
354                'trace'          => wpcom_layout_grid_usage_attribute_source(),
355            ),
356            $extra
357        );
358
359        \Automattic\Jetpack\Jetpack_Mu_Wpcom::log2logstash(
360            WPCOM_LAYOUT_GRID_USAGE_LOG_FEATURE,
361            WPCOM_LAYOUT_GRID_USAGE_LOG_MESSAGE,
362            wpcom_layout_grid_usage_redact_paths( $payload )
363        );
364        return true;
365    } catch ( \Throwable $e ) {
366        unset( $e );
367        return false;
368    }
369}
370
371/**
372 * Walk `debug_backtrace()` and return up to 8 `<file>:<line>` strings for
373 * frames under `wp-content/(plugins|themes|mu-plugins)/`. Core / pluggable
374 * frames are filtered out. `wp_debug_backtrace_summary()` is deliberately
375 * not used: it returns function-call summaries, not file paths.
376 *
377 * @return string[]
378 */
379function wpcom_layout_grid_usage_attribute_source() {
380    // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Attribution backtrace for a logstash record.
381    $frames    = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS );
382    $self_file = __FILE__;
383    $relevant  = array();
384    foreach ( $frames as $frame ) {
385        $entry = wpcom_layout_grid_usage_format_attribution_frame( $frame, $self_file );
386        if ( null === $entry ) {
387            continue;
388        }
389        $relevant[] = $entry;
390        if ( count( $relevant ) >= 8 ) {
391            break;
392        }
393    }
394    return $relevant;
395}
396
397/**
398 * Per-frame predicate. Returns `<file>:<line>` for kept frames, null to skip.
399 * Skips: malformed frames, the tracker's own file (jetpack-mu-wpcom loads
400 * from `wp-content/mu-plugins/` and would otherwise dominate the 8-frame cap),
401 * and any file outside the three extension directories. Extracted from
402 * `wpcom_layout_grid_usage_attribute_source()` for unit-testability.
403 *
404 * @param mixed  $frame     One frame from `debug_backtrace()`.
405 * @param string $self_file Tracker file path for the self-skip comparison.
406 * @return string|null
407 */
408function wpcom_layout_grid_usage_format_attribution_frame( $frame, string $self_file ) {
409    if ( ! is_array( $frame ) || empty( $frame['file'] ) || ! is_string( $frame['file'] ) ) {
410        return null;
411    }
412    if ( $frame['file'] === $self_file ) {
413        return null;
414    }
415    if ( ! preg_match( '#/wp-content/(plugins|themes|mu-plugins)/#', $frame['file'] ) ) {
416        return null;
417    }
418    $line = isset( $frame['line'] ) ? (int) $frame['line'] : 0;
419    return $frame['file'] . ':' . $line;
420}
421
422/**
423 * Strip ABSPATH / WP_CONTENT_DIR prefixes from string values, recursing into
424 * arrays. Mirrors `pcg_log_redact_paths` in Plugin Conflicts Guardian.
425 *
426 * @param mixed $value Scalar or array.
427 * @return mixed
428 */
429function wpcom_layout_grid_usage_redact_paths( $value ) {
430    if ( is_array( $value ) ) {
431        return array_map( 'wpcom_layout_grid_usage_redact_paths', $value );
432    }
433    if ( ! is_string( $value ) || '' === $value ) {
434        return $value;
435    }
436    $replacements = array();
437    if ( defined( 'WP_CONTENT_DIR' ) && '' !== WP_CONTENT_DIR ) {
438        $replacements[ rtrim( WP_CONTENT_DIR, '/' ) . '/' ] = '.../';
439    }
440    if ( defined( 'ABSPATH' ) && '' !== ABSPATH ) {
441        $replacements[ rtrim( ABSPATH, '/' ) . '/' ] = '.../';
442    }
443    if ( empty( $replacements ) ) {
444        return $value;
445    }
446    return strtr( $value, $replacements );
447}