PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 1.7.0.9 All 211 releases
charitable / includes / abilities / class-charitable-abilities-usage.php

class-charitable-abilities-usage.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.13, at includes/abilities/class-charitable-abilities-usage.php

521 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Counts how the Charitable abilities surface is actually being used.
4 *
5 * WHY THIS IS NOT DERIVED FROM THE LOG. Tools > Logs already records ability
6 * activity, so a counter looks redundant until you ask it the one question the
7 * feature needs answered — "is anyone using this?" — and find the log cannot
8 * answer it:
9 *
10 * - **Reads are not logged, by design.** `log_read()` sits behind the
11 * `charitable_abilities_log_reads` filter, off by default, because two dozen
12 * read abilities inside an agentic loop would fill the table with rows nobody
13 * reads. A WPVibe session is mostly reads, so a healthy integration writes
14 * almost nothing to the log.
15 * - **Logging can be switched off entirely** (`Charitable_Log::is_enabled()`),
16 * which takes a log-derived count to exactly zero.
17 * - **Retention prunes rows** after 30 days, or 90 with Recurring.
18 *
19 * All three make "working perfectly" and "never connected" produce the same
20 * log. This counter is therefore deliberately independent of it, and
21 * Test_Charitable_Abilities_Usage_Counter pins that independence.
22 *
23 * WHAT IT DELIBERATELY DOES NOT STORE. The counter is the source for a field
24 * that leaves the site in the usage check-in, so the privacy line is drawn
25 * here rather than at the payload boundary: only the closed vocabulary of
26 * registered ability slugs, four integers and two dates. Never ability input
27 * or output — `wp_ability_invoked` hands us `$input`, which carries campaign
28 * titles, donor names and amounts, and it is never read. Never log text,
29 * never object ids, never a user id.
30 *
31 * ⚠️ THESE COUNTS ARE A FLOOR, NOT AN EXACT TALLY. flush() is a
32 * read-modify-write of one serialised option, and the options API offers no
33 * atomic increment, so two requests invoking abilities at the same moment can
34 * each read the same stored value and one delta is lost. MCP clients do issue
35 * tool calls in parallel, so this is reachable rather than theoretical.
36 *
37 * Accepted rather than fixed, because every fix is worse: a row per call
38 * recreates the log-flooding problem the log already has, and a lock or a
39 * separate table is a schema change to answer "is anyone using this". The
40 * questions this data serves — is it used, by how many sites, for what, does it
41 * succeed, do they stay — are all answered by magnitude and by the dates.
42 * **Do not build anything that needs these to be exact,** and do not reconcile
43 * them against the log: the two measure deliberately different populations
44 * (the log cannot see reads; this cannot see anything before 1.8.13).
45 *
46 * WHAT IS PER-ABILITY AND WHAT IS NOT. `calls` is per-ability; the four
47 * outcomes are site-wide totals. That split is deliberate, not an oversight:
48 * per-ability outcomes would double the payload map to answer a question the
49 * log already answers better, since log_failure() and log_denial() both record
50 * the ability name, and System Info surfaces the most recent of each.
51 *
52 * @package Charitable/Classes/Charitable_Abilities_Usage
53 * @copyright Copyright (c) 2026, WP Charitable LLC
54 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
55 * @since 1.8.13
56 * @version 1.8.13
57 */
58
59 if ( ! defined( 'ABSPATH' ) ) {
60 exit;
61 }
62
63 if ( ! class_exists( 'Charitable_Abilities_Usage' ) ) :
64
65 /**
66 * Charitable_Abilities_Usage
67 *
68 * @since 1.8.13
69 */
70 class Charitable_Abilities_Usage {
71
72 /**
73 * The option the counter persists to.
74 *
75 * Never autoloaded. It is read twice — once in Tools > System Info and
76 * once in the weekly check-in — and holds up to 29 keys, so autoloading
77 * it would add it to the alloptions blob of every request forever to
78 * serve two readers a week.
79 *
80 * @since 1.8.13
81 * @var string
82 */
83 const OPTION = 'charitable_abilities_usage';
84
85 /**
86 * Stored shape version, so a future change can migrate rather than guess.
87 *
88 * @since 1.8.13
89 * @var int
90 */
91 const VERSION = 1;
92
93 /**
94 * The ability-name prefix this class counts.
95 *
96 * @since 1.8.13
97 * @var string
98 */
99 const PREFIX = 'charitable/';
100
101 /**
102 * Invocations this request, slug => count.
103 *
104 * @since 1.8.13
105 * @var array<string,int>
106 */
107 private static $calls = array();
108
109 /**
110 * Successful executions this request.
111 *
112 * @since 1.8.13
113 * @var int
114 */
115 private static $ok = 0;
116
117 /**
118 * Permission denials this request.
119 *
120 * @since 1.8.13
121 * @var int
122 */
123 private static $denied = 0;
124
125 /**
126 * Writes refused by the AI write gate this request.
127 *
128 * @since 1.8.13
129 * @var int
130 */
131 private static $refused = 0;
132
133 /**
134 * Whether anything happened worth a database write.
135 *
136 * Tracked separately rather than inferred from the counters above,
137 * because a request that recorded nothing must not write the option at
138 * all — this runs on `shutdown` for EVERY request on the site.
139 *
140 * @since 1.8.13
141 * @var bool
142 */
143 private static $touched = false;
144
145 /**
146 * Record that an ability was invoked.
147 *
148 * Hooked from the `wp_ability_invoked` handler, which core fires once
149 * per `execute()` call before any processing — so this counts calls
150 * regardless of how they end.
151 *
152 * That seam matters. Core evaluates permission callbacks on its own to
153 * decide which abilities to even LIST, and the REST list route does it
154 * for every registered ability on every request. Counting from inside a
155 * permission callback would therefore count listings, not uses, and a
156 * single client connecting would report 29 calls.
157 *
158 * @since 1.8.13
159 *
160 * @param string $ability_name The ability being invoked.
161 * @return void
162 */
163 public static function record_invocation( $ability_name ) {
164 $slug = self::slug( $ability_name );
165
166 if ( '' === $slug ) {
167 return;
168 }
169
170 if ( ! isset( self::$calls[ $slug ] ) ) {
171 self::$calls[ $slug ] = 0;
172 }
173
174 ++self::$calls[ $slug ];
175 self::$touched = true;
176 }
177
178 /**
179 * Record a successful execution.
180 *
181 * Hooked to `wp_after_execute_ability`, which core fires ONLY on
182 * success: `do_execute()` returning a WP_Error returns from `execute()`
183 * before the action runs, and so does a failed output validation. That
184 * asymmetry is what lets `failed` be derived in get() instead of needing
185 * a seam of its own — there is no "ability failed" action in core to
186 * hook even if we wanted one.
187 *
188 * @since 1.8.13
189 *
190 * @param string $ability_name The ability that executed.
191 * @return void
192 */
193 public static function record_success( $ability_name ) {
194 if ( '' === self::slug( $ability_name ) ) {
195 return;
196 }
197
198 ++self::$ok;
199 self::$touched = true;
200 }
201
202 /**
203 * Record a permission denial.
204 *
205 * Kept apart from failures because support and product read them
206 * differently: a denial is about who is calling — credentials, roles,
207 * capabilities — while a failure is about a bug or bad input.
208 *
209 * @since 1.8.13
210 *
211 * @param string $ability_name The ability that was refused.
212 * @return void
213 */
214 public static function record_denial( $ability_name ) {
215 if ( '' === self::slug( $ability_name ) ) {
216 return;
217 }
218
219 ++self::$denied;
220 self::$touched = true;
221 }
222
223 /**
224 * Record a write refused by the AI write gate.
225 *
226 * Distinct from a denial: the caller had every permission, the site
227 * simply has AI writes switched off. Fleet-wide that is a product
228 * signal — customers who have the feature and have not turned it on —
229 * rather than a fault, so it must not be mixed in with either denials
230 * or failures.
231 *
232 * @since 1.8.13
233 *
234 * @param string $ability_name The ability that was refused.
235 * @return void
236 */
237 public static function record_refusal( $ability_name ) {
238 if ( '' === self::slug( $ability_name ) ) {
239 return;
240 }
241
242 ++self::$refused;
243 self::$touched = true;
244 }
245
246 /**
247 * Persist this request's counts, merging into whatever is stored.
248 *
249 * Called on `shutdown`. Counting happens in memory and lands in one
250 * option write per request, rather than a write per ability call: an
251 * MCP client drives one ability per HTTP request, but an in-process
252 * agentic loop can drive dozens, and a write per call is the kind of
253 * cost that resurfaces later as a ticket about admin slowness.
254 *
255 * @since 1.8.13
256 *
257 * @return void
258 */
259 public static function flush() {
260 if ( ! self::$touched ) {
261 return;
262 }
263
264 $stored = self::stored();
265
266 foreach ( self::$calls as $slug => $count ) {
267 $previous = isset( $stored['calls'][ $slug ] ) ? (int) $stored['calls'][ $slug ] : 0;
268 $stored['calls'][ $slug ] = $previous + (int) $count;
269 }
270
271 $stored['ok'] = (int) $stored['ok'] + self::$ok;
272 $stored['denied'] = (int) $stored['denied'] + self::$denied;
273 $stored['refused'] = (int) $stored['refused'] + self::$refused;
274
275 /*
276 * Day granularity, not a timestamp. Every adoption and retention
277 * question this feeds is answered by a date, and a date cannot be
278 * correlated back to an individual administrator's working pattern
279 * the way a time-of-day can.
280 */
281 $today = gmdate( 'Y-m-d' );
282
283 if ( '' === (string) $stored['first_at'] ) {
284 $stored['first_at'] = $today;
285 }
286
287 $stored['last_at'] = $today;
288 $stored['v'] = self::VERSION;
289
290 update_option( self::OPTION, $stored, false );
291
292 self::$calls = array();
293 self::$ok = 0;
294 self::$denied = 0;
295 self::$refused = 0;
296 self::$touched = false;
297 }
298
299 /**
300 * The counter, with failures and the invocation total derived.
301 *
302 * Counts are CUMULATIVE and reading never clears them. Deliberately not
303 * a window that resets when a check-in succeeds: the check-in pipeline's
304 * own history is the argument against that. record_checkin_failure()
305 * exists because sends fail, and the great majority of reporting sites
306 * have historically managed a single check-in ever. A window that clears
307 * on send loses its data every time a send fails, silently; a cumulative
308 * total lets a receiver diff successive snapshots and lose nothing.
309 *
310 * @since 1.8.13
311 *
312 * @return array
313 */
314 public static function get() {
315 $stored = self::stored();
316
317 $calls = array();
318
319 foreach ( (array) $stored['calls'] as $slug => $count ) {
320 $calls[ (string) $slug ] = (int) $count;
321 }
322
323 $total = array_sum( $calls );
324 $ok = (int) $stored['ok'];
325 $denied = (int) $stored['denied'];
326 $refused = (int) $stored['refused'];
327
328 /*
329 * Clamped at zero rather than allowed to go negative. The three
330 * counted outcomes and the invocation total are written by different
331 * seams, so a request that dies between them - a fatal inside an
332 * execute callback, a PHP timeout - can leave ok+denied+refused one
333 * ahead of total. A negative "failures" figure in a support paste
334 * would be read as a bug in Charitable rather than as the rounding
335 * artefact it is.
336 *
337 * A second, distinct reason for this same clamp, added in Lite 1.8.13:
338 * wp_after_execute_ability is core @since 6.9.0, but wp_ability_invoked
339 * is @since 7.1.0. On WordPress 6.9 and 7.0.x, record_success() (hooked
340 * to the former) runs while record_invocation() (hooked to the latter)
341 * never fires at all, so $total stays at 0 while $ok climbs — the exact
342 * same shape of negative that the clamp above already exists to catch,
343 * from a different cause. This is a live defect in shipped Pro too, not
344 * a Lite-only concern; see get_counts() for the companion field that
345 * lets a consumer tell "nothing happened" apart from "this core cannot
346 * tell us".
347 *
348 * @since 1.8.13
349 */
350 $failed = max( 0, $total - $ok - $denied - $refused );
351
352 return array(
353 'v' => (int) $stored['v'],
354 'calls' => $calls,
355 'total' => (int) $total,
356 'ok' => $ok,
357 'denied' => $denied,
358 'refused' => $refused,
359 'failed' => $failed,
360 'first_at' => (string) $stored['first_at'],
361 'last_at' => (string) $stored['last_at'],
362 );
363 }
364
365 /**
366 * The counter from get(), plus whether this core can see every invocation.
367 *
368 * Added in Lite 1.8.13 as the public-facing wrapper System Info's own
369 * usage_summary()/busiest_abilities() and the ported Pro test suite do NOT
370 * use — both call get() directly, verbatim, and continue to work unchanged.
371 * This method exists for consumers (the weekly usage check-in, and any new
372 * caller) who need the observation floor alongside the counts, without
373 * changing get()'s own return shape.
374 *
375 * @since 1.8.13
376 *
377 * @return array get()'s array, plus 'observation_complete'.
378 */
379 public static function get_counts() {
380 $counts = self::get();
381
382 /*
383 * True when this core can see every invocation. Below WP 7.1 the
384 * wp_ability_invoked action does not exist, so `calls` reads 0 on a
385 * site an assistant is actively using. Reporting the floor is the
386 * difference between "nothing happened" and "we cannot tell".
387 *
388 * @since 1.8.13
389 */
390 $counts['observation_complete'] = (int) did_action( 'wp_ability_invoked' ) > 0
391 || version_compare( get_bloginfo( 'version' ), '7.1', '>=' );
392
393 return $counts;
394 }
395
396 /**
397 * Clear both halves of the counter. Test seam.
398 *
399 * The in-memory half has to be cleared explicitly: statics survive the
400 * per-test transaction rollback that resets options, so without this
401 * counts leak from one test into the next.
402 *
403 * @since 1.8.13
404 *
405 * @return void
406 */
407 public static function reset() {
408 self::$calls = array();
409 self::$ok = 0;
410 self::$denied = 0;
411 self::$refused = 0;
412 self::$touched = false;
413
414 delete_option( self::OPTION );
415 }
416
417 /**
418 * The stored counter, with every key guaranteed present.
419 *
420 * A non-array option degrades to empty rather than fatalling: this is
421 * read inside a cron check-in and inside a System Info render, and
422 * neither is a place to discover that somebody hand-edited an option.
423 *
424 * @since 1.8.13
425 *
426 * @return array
427 */
428 private static function stored() {
429 $stored = get_option( self::OPTION, array() );
430
431 if ( ! is_array( $stored ) ) {
432 $stored = array();
433 }
434
435 $stored = array_merge(
436 array(
437 'v' => self::VERSION,
438 'calls' => array(),
439 'ok' => 0,
440 'denied' => 0,
441 'refused' => 0,
442 'first_at' => '',
443 'last_at' => '',
444 ),
445 $stored
446 );
447
448 if ( ! is_array( $stored['calls'] ) ) {
449 $stored['calls'] = array();
450 }
451
452 return $stored;
453 }
454
455 /**
456 * The unprefixed slug of a REGISTERED Charitable ability, or '' otherwise.
457 *
458 * The prefix is dropped because every key in the stored map is a
459 * Charitable ability by construction, so carrying `charitable/` would
460 * repeat eleven bytes of nothing up to twenty-nine times in a payload
461 * field.
462 *
463 * THE REGISTRY CHECK IS NOT BELT-AND-BRACES. Without it this method
464 * accepts any `charitable/`-prefixed string, and `wp_ability_invoked` is
465 * a public action that anything on the site can fire — so one buggy
466 * do_action() in a loop could grow the stored option without bound, a
467 * key at a time. Renaming an ability between releases would also leave
468 * its old key in the map permanently.
469 *
470 * It is also what makes the vocabulary genuinely CLOSED rather than
471 * closed by convention, and two things downstream depend on that being
472 * true: the privacy contract (nothing but known slugs leaves the site)
473 * and the receiver's allowlist, which drops keys it does not recognise.
474 *
475 * Nothing real is lost by requiring registration. An unregistered
476 * ability cannot be executed through core in the first place —
477 * execute() is a method on a WP_Ability the registry handed out — so the
478 * only callers this rejects are ones faking the action.
479 *
480 * @since 1.8.13
481 *
482 * @param mixed $ability_name The ability name.
483 * @return string
484 */
485 private static function slug( $ability_name ) {
486 if ( ! is_string( $ability_name ) || 0 !== strpos( $ability_name, self::PREFIX ) ) {
487 return '';
488 }
489
490 /*
491 * No registry means no Abilities API, which means no ability can
492 * have run. Counting nothing is the correct answer, not a fallback
493 * to the prefix check.
494 */
495 if ( ! class_exists( 'WP_Abilities_Registry' ) ) {
496 return '';
497 }
498
499 $registry = WP_Abilities_Registry::get_instance();
500
501 /*
502 * is_registered() and NOT wp_get_ability(). The latter routes through
503 * the registry's get_registered(), which fires _doing_it_wrong for a
504 * name it does not hold ("Ability ... not found", @since 6.9.0). That
505 * would turn this guard into a notice emitter: every rejected name —
506 * exactly the anomalous case the guard exists for — would raise a
507 * Charitable-attributed notice on any site running WP_DEBUG, and
508 * `wp_ability_invoked` is a public action, so the offending call need
509 * not even be ours. is_registered() is a bare isset() and says
510 * nothing. Caught by the test suite's incorrect-usage detection.
511 */
512 if ( null === $registry || ! $registry->is_registered( $ability_name ) ) {
513 return '';
514 }
515
516 return substr( $ability_name, strlen( self::PREFIX ) );
517 }
518 }
519
520 endif;
521