← All changes
|
includes/admin/tracking/class-charitable-tracking.php
+241
-0
1.8.12.3
→
1.8.13
View file →
| @@ -175,8 +175,40 @@ | ||
| 175 | 175 | $data['recommended_plugins_installed'] = get_option( 'charitable_recommended_plugins_installed', array() ); |
| 176 | 176 | $data['recommended_plugins_activated'] = get_option( 'charitable_recommended_plugins_activated', array() ); |
| 177 | 177 | $data['dashboard_enhance_section_views'] = get_option( 'charitable_dashboard_enhance_views', 0 ); |
| 178 | 178 | |
| 179 | + /* | |
| 180 | + * Added BEFORE the two plugin lists, deliberately. | |
| 181 | + * | |
| 182 | + * The body is form-encoded and the receiver runs | |
| 183 | + * max_input_vars = 1000 (measured), which PHP enforces by silently | |
| 184 | + * DROPPING variables past the limit rather than erroring — so | |
| 185 | + * whatever is last in this array is first to be lost. active_plugins | |
| 186 | + * and inactive_plugins are the only unbounded part of this payload, | |
| 187 | + * and they are also the least valuable, so the bounded fields belong | |
| 188 | + * in front of them. | |
| 189 | + * | |
| 190 | + * @since 1.8.13 | |
| 191 | + */ | |
| 192 | + $data['abilities'] = $this->get_abilities_data(); | |
| 193 | + | |
| 194 | + /* | |
| 195 | + * Mirrors the cross-brand AM promo-attribution loop. The options are | |
| 196 | + * unprefixed; we claim only installs whose source names Charitable. | |
| 197 | + * | |
| 198 | + * @since 1.8.13 | |
| 199 | + */ | |
| 200 | + $promo = array( 'wpconsent', 'sugar-calendar', 'duplicator', 'uncannyautomator', 'activelayer', 'wpvibe' ); | |
| 201 | + | |
| 202 | + foreach ( $promo as $plugin ) { | |
| 203 | + $source = (string) get_option( $plugin . '_source', '' ); | |
| 204 | + $date = (int) get_option( $plugin . '_date', 0 ); | |
| 205 | + | |
| 206 | + if ( $date && false !== strpos( $source, 'Charitable' ) ) { | |
| 207 | + $data[ 'charitable_' . $plugin . '_date' ] = $date; | |
| 208 | + } | |
| 209 | + } | |
| 210 | + | |
| 179 | 211 | // Retrieve current plugin information. |
| 180 | 212 | if ( ! function_exists( 'get_plugins' ) ) { |
| 181 | 213 | include ABSPATH . '/wp-admin/includes/plugin.php'; |
| 182 | 214 | } |
| @@ -195,8 +227,217 @@ | ||
| 195 | 227 | $data['inactive_plugins'] = $plugins; |
| 196 | 228 | $data['locale'] = get_locale(); |
| 197 | 229 | |
| 198 | 230 | return $data; |
| 231 | + } | |
| 232 | + | |
| 233 | + /** | |
| 234 | + * Abilities / AI-assistant usage for the opt-in check-in. | |
| 235 | + * | |
| 236 | + * Fifteen fields: six integers, five booleans, two dates, one client | |
| 237 | + * slug and one JSON map. Nothing else, and in particular nothing that | |
| 238 | + * identifies a campaign, a donation, a donor or an administrator — the | |
| 239 | + * whole payload is counts drawn from Charitable_Abilities_Usage, whose | |
| 240 | + * stored keys are a closed vocabulary of registered ability slugs. | |
| 241 | + * | |
| 242 | + * A `scopes` map was briefly the sixteenth, reporting which of three | |
| 243 | + * write permissions a site had granted. Write access went back to one | |
| 244 | + * switch before 1.8.13 shipped, and `write` already carries that. | |
| 245 | + * | |
| 246 | + * PUBLIC on purpose, like get_charitable_data() and get_donation_data(). | |
| 247 | + * get_optin_data() is private, so a block built inline there cannot be | |
| 248 | + * asserted by a test, and this is the payload where an untested field is | |
| 249 | + * most expensive: the usage receiver reads named keys one at a time, so a | |
| 250 | + * field that is misnamed or the wrong shape is not an error anywhere — it | |
| 251 | + * is silently dropped on arrival and cannot be backfilled. | |
| 252 | + * | |
| 253 | + * ⚠️ TWO FIELDS ARE DELIBERATELY ABSENT and must not be added here. | |
| 254 | + * `rest_visible` (the count of abilities a REST client can actually see) | |
| 255 | + * and application-password availability are both measured by seams that | |
| 256 | + * are invalid without an authenticated HTTPS request: the first needs | |
| 257 | + * rest_do_request(), which returns an empty list with no user, and the | |
| 258 | + * second consults is_ssl(), which is false with no request. THIS RUNS IN | |
| 259 | + * CRON. Both would report "0 visible" and "not available" across the | |
| 260 | + * entire fleet of healthy sites — and both already did exactly that under | |
| 261 | + * WP-CLI, which is why the two System Info lines that report them refuse | |
| 262 | + * to measure rather than answer. A false catastrophe in a dataset nobody | |
| 263 | + * thinks to distrust is worse than a missing column. | |
| 264 | + * | |
| 265 | + * `observation_complete` IS NEW HERE, added in Lite 1.8.13 and not present | |
| 266 | + * in Pro's version of this method. It reads | |
| 267 | + * Charitable_Abilities_Usage::get_counts() rather than ::get() for exactly | |
| 268 | + * that field: wp_after_execute_ability is core @since 6.9.0 but | |
| 269 | + * wp_ability_invoked is @since 7.1.0, so on WordPress 6.9 and 7.0.x | |
| 270 | + * `calls`/`total` read 0 while `ok` can still climb. Without this flag a | |
| 271 | + * quiet WP-7.0 site and an unused site are indistinguishable on the | |
| 272 | + * receiving end. | |
| 273 | + * | |
| 274 | + * @since 1.8.13 | |
| 275 | + * | |
| 276 | + * @return array<string,int|string> | |
| 277 | + */ | |
| 278 | + public function get_abilities_data() { | |
| 279 | + | |
| 280 | + $usage = class_exists( 'Charitable_Abilities_Usage' ) | |
| 281 | + ? Charitable_Abilities_Usage::get_counts() | |
| 282 | + : array( | |
| 283 | + 'calls' => array(), | |
| 284 | + 'total' => 0, | |
| 285 | + 'ok' => 0, | |
| 286 | + 'denied' => 0, | |
| 287 | + 'refused' => 0, | |
| 288 | + 'failed' => 0, | |
| 289 | + 'first_at' => '', | |
| 290 | + 'last_at' => '', | |
| 291 | + 'observation_complete' => 0, | |
| 292 | + ); | |
| 293 | + | |
| 294 | + /* | |
| 295 | + * Integers, not booleans, for the flags. On the wire the two are | |
| 296 | + * identical — http_build_query() encodes true/false as 1/0 — and the | |
| 297 | + * receiver parses with filter_var( FILTER_VALIDATE_BOOLEAN ), which | |
| 298 | + * takes either. Casting here is so the shape is unambiguous to a test | |
| 299 | + * and to anyone reading a logged payload, where a bare `false` would | |
| 300 | + * render as an empty string. | |
| 301 | + */ | |
| 302 | + return array( | |
| 303 | + 'api' => (int) function_exists( 'wp_register_ability' ), | |
| 304 | + 'registered' => $this->count_registered_abilities(), | |
| 305 | + 'write' => (int) ( class_exists( 'Charitable_Abilities_Registrar' ) && Charitable_Abilities_Registrar::write_enabled() ), | |
| 306 | + 'client' => $this->get_mcp_client_slug(), | |
| 307 | + 'logs' => (int) ( ! class_exists( 'Charitable_Log' ) || Charitable_Log::is_enabled() ), | |
| 308 | + | |
| 309 | + /* | |
| 310 | + * Passed the empty string as the second argument because the | |
| 311 | + * filter is per-ability and there is no ability in hand here. | |
| 312 | + * This reports the site's DEFAULT answer, which is the only | |
| 313 | + * meaningful one fleet-wide. | |
| 314 | + */ | |
| 315 | + 'read_logs' => (int) apply_filters( 'charitable_abilities_log_reads', false, '' ), | |
| 316 | + | |
| 317 | + /* | |
| 318 | + * A JSON STRING, never a nested array, and this is load-bearing | |
| 319 | + * twice over. A nested abilities[calls][list-campaigns]=41 shape | |
| 320 | + * invites the `[]=x` payload that has twice forced a guard into | |
| 321 | + * the receiver (a (string) cast on an array raises a warning that | |
| 322 | + * Laravel rethrows as an ErrorException, 500ing an otherwise good | |
| 323 | + * check-in). And the body is form-encoded, so a 24-slug map is 24 | |
| 324 | + * more PHP input variables against a max_input_vars budget this | |
| 325 | + * payload already spends heavily on the full active and inactive | |
| 326 | + * plugin lists. One string is one variable. | |
| 327 | + * | |
| 328 | + * Cast to object so an EMPTY map encodes as `{}` rather than | |
| 329 | + * `[]`. wp_json_encode( array() ) emits a JSON array, so without | |
| 330 | + * the cast this field is an object on a site that has used the | |
| 331 | + * feature and an array on one that has not — and most sites have | |
| 332 | + * not, so the array form would be the common case for a field | |
| 333 | + * documented as a map. The receiver tolerates both, but a type | |
| 334 | + * that changes with the data is the kind of thing that breaks a | |
| 335 | + * consumer written against the common case years later. | |
| 336 | + */ | |
| 337 | + 'calls' => (string) wp_json_encode( (object) (array) $usage['calls'] ), | |
| 338 | + | |
| 339 | + /* | |
| 340 | + * Sent rather than recovered by summing `calls` receiver-side. | |
| 341 | + * get_counts() already computes it, so this passes along a value | |
| 342 | + * that exists; without it the headline usage number is only | |
| 343 | + * reachable by summing a JSON blob in every query, against no | |
| 344 | + * index. | |
| 345 | + */ | |
| 346 | + 'total' => (int) $usage['total'], | |
| 347 | + 'ok' => (int) $usage['ok'], | |
| 348 | + 'denied' => (int) $usage['denied'], | |
| 349 | + 'refused' => (int) $usage['refused'], | |
| 350 | + 'failed' => (int) $usage['failed'], | |
| 351 | + 'first_at' => (string) $usage['first_at'], | |
| 352 | + 'last_at' => (string) $usage['last_at'], | |
| 353 | + 'observation_complete' => (int) $usage['observation_complete'], | |
| 354 | + ); | |
| 355 | + } | |
| 356 | + | |
| 357 | + /** | |
| 358 | + * How many Charitable abilities are registered. | |
| 359 | + * | |
| 360 | + * A weak signal deliberately kept: one smallint that reveals a build or | |
| 361 | + * a partial deploy where some abilities failed to register at all. | |
| 362 | + * | |
| 363 | + * ⚠️ NOTE THE SIDE EFFECT, which is the reason this is one line of code | |
| 364 | + * and a paragraph of comment. wp_get_abilities() reaches | |
| 365 | + * WP_Abilities_Registry::get_instance(), which fires | |
| 366 | + * `wp_abilities_api_init` lazily on first use — so asking for this count | |
| 367 | + * inside the check-in FORCES the whole abilities surface to register: | |
| 368 | + * four registrar objects and twenty-four wp_register_ability() calls | |
| 369 | + * with their full input and output schemas, in a weekly cron request | |
| 370 | + * that would otherwise never touch any of it. | |
| 371 | + * | |
| 372 | + * Accepted, because it is once a week and the alternative is worse. A | |
| 373 | + * hardcoded 24 drifts the moment an ability is added or removed, and | |
| 374 | + * this field exists precisely to catch a build where the real number is | |
| 375 | + * not 24 — a count that cannot disagree with reality reports nothing. | |
| 376 | + * | |
| 377 | + * Do NOT reach for a cached value here either: a cache populated during | |
| 378 | + * a normal admin request would report yesterday's build in today's | |
| 379 | + * check-in, which is the same failure in slower motion. | |
| 380 | + * | |
| 381 | + * @since 1.8.13 | |
| 382 | + * | |
| 383 | + * @return int | |
| 384 | + */ | |
| 385 | + private function count_registered_abilities() { | |
| 386 | + | |
| 387 | + if ( ! function_exists( 'wp_get_abilities' ) ) { | |
| 388 | + return 0; | |
| 389 | + } | |
| 390 | + | |
| 391 | + $count = 0; | |
| 392 | + | |
| 393 | + foreach ( wp_get_abilities() as $ability ) { | |
| 394 | + if ( is_object( $ability ) && method_exists( $ability, 'get_name' ) && 0 === strpos( $ability->get_name(), 'charitable/' ) ) { | |
| 395 | + ++$count; | |
| 396 | + } | |
| 397 | + } | |
| 398 | + | |
| 399 | + return $count; | |
| 400 | + } | |
| 401 | + | |
| 402 | + /** | |
| 403 | + * The slug of the MCP client this site is running, or '' for none. | |
| 404 | + * | |
| 405 | + * Detection shares Charitable_Abilities' basename map with the System | |
| 406 | + * Info screen, so the two cannot disagree about whether a client is | |
| 407 | + * present. Almost every reporting site is expected to say `wpvibe`; the | |
| 408 | + * value of the field is the day one does not. | |
| 409 | + * | |
| 410 | + * First match wins where two are somehow active. A list would be a | |
| 411 | + * second JSON column to express a state that should not occur, and the | |
| 412 | + * question this answers is "which client", not "how many". | |
| 413 | + * | |
| 414 | + * DELIBERATELY NOT FILTERED, and the asymmetry is worth stating because | |
| 415 | + * it otherwise reads as an oversight: `charitable_abilities_mcp_clients` | |
| 416 | + * changes what System Info displays but has no effect here. That filter | |
| 417 | + * takes and returns human-readable LABELS for a pasted report, so it | |
| 418 | + * cannot supply the slug this field needs, and quietly running labels | |
| 419 | + * through here would write values the receiver sanitises away. A site | |
| 420 | + * using that filter to declare a bespoke client therefore shows it on | |
| 421 | + * screen and reports no client in telemetry, which is the right answer | |
| 422 | + * of the two available: the slug vocabulary is shared with the receiver, | |
| 423 | + * so it is not a site's to extend. | |
| 424 | + * | |
| 425 | + * @since 1.8.13 | |
| 426 | + * | |
| 427 | + * @return string | |
| 428 | + */ | |
| 429 | + private function get_mcp_client_slug() { | |
| 430 | + | |
| 431 | + if ( ! class_exists( 'Charitable_Abilities' ) ) { | |
| 432 | + return ''; | |
| 433 | + } | |
| 434 | + | |
| 435 | + foreach ( Charitable_Abilities::get_active_mcp_clients() as $entry ) { | |
| 436 | + return (string) $entry['slug']; | |
| 437 | + } | |
| 438 | + | |
| 439 | + return ''; | |
| 199 | 440 | } |
| 200 | 441 | |
| 201 | 442 | |
| 202 | 443 | /** |