PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / includes / class-metasync-posthog.php

class-metasync-posthog.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at includes/class-metasync-posthog.php

744 lines 30.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Consent-gated PostHog product analytics for MetaSync.
4 *
5 * Only explicit server-side events are sent. Autocapture and session recording
6 * are intentionally not used. High-frequency activity (admin page views,
7 * platform polling, automated purges/submits, backoff, editor micro-actions)
8 * is not tracked at all. Activity that can repeat or burst (MCP write tools,
9 * Content Genius syncs, platform imports, editor saves) is counted and rolled
10 * up instead of sent per-event, and recurring failure signals are deduped.
11 * Every event carries a `site` group so user-0 (cron/platform) events and
12 * admin events can be joined per site.
13 *
14 * @package Metasync
15 */
16
17 if (!defined('WPINC')) {
18 die;
19 }
20
21 class Metasync_PostHog {
22 private const OPT_IN_OPTION = 'metasync_analytics_opt_in';
23 private const EXPLICIT_CONSENT_OPTION = 'metasync_analytics_consent_explicit';
24 private const VERSION_OPTION = 'metasync_analytics_version';
25 private const DEFAULT_HOST = 'https://posthog.internal.searchatlas.com';
26 private const BATCH_PATH = '/batch/';
27 private const GROUP_TYPE = 'site';
28 private const GROUP_IDENTIFIED_OPTION = 'metasync_ph_group_identified';
29 private const ROLLUP_PREFIX = 'metasync_ph_rollup_';
30 private const ROLLUP_FLUSH_HOOK = 'metasync_ph_flush_rollups';
31 private const ONCE_PREFIX = 'metasync_ph_once_';
32 // Must not share ROLLUP_PREFIX: flush_rollups() scans that prefix.
33 private const ROLLUP_LOCK_PREFIX = 'metasync_ph_lock_rollup_';
34 private const ROLLUP_LOCK_STALE_AFTER = 30;
35 private static $instance = null;
36
37 public static function get_instance() {
38 if (self::$instance === null) {
39 self::$instance = new self();
40 }
41 return self::$instance;
42 }
43
44 private function __construct() {
45 add_action('metasync_analytics_event', array($this, 'handle_event'), 10, 2);
46 add_action('metasync_analytics_event_once', array($this, 'handle_event_once'), 10, 4);
47 add_action('metasync_analytics_rollup', array($this, 'track_rollup'), 10, 3);
48 add_action('metasync_analytics_import_batch', array($this, 'track_import_batch'), 10, 5);
49 add_action(self::ROLLUP_FLUSH_HOOK, array($this, 'flush_rollups'));
50 add_action('metasync_mcp_tool_executed', array($this, 'track_mcp_tool'), 10, 4);
51 add_action('metasync_content_genius_sync', array($this, 'track_content_genius_sync'), 10, 3);
52 add_filter('rest_request_after_callbacks', array($this, 'track_rest_request'), 10, 3);
53 add_action('init', array($this, 'run_version_check'), 2);
54 }
55
56 /**
57 * One-time-per-version checks: version adoption and consent migration.
58 *
59 * Legacy GA4 builds force-wrote the opt-in option to 'yes' whenever it was
60 * missing, so an existing 'yes' was never a deliberate user choice. When
61 * this plugin version first runs, reset an inherited 'yes' unless consent
62 * was given explicitly through the wizard or settings toggle afterwards.
63 *
64 * A version change also reports plugin_updated (adoption signal);
65 * plugin_uninstalled is fired from uninstall.php.
66 */
67 public function run_version_check() {
68 $version = defined('METASYNC_VERSION') ? METASYNC_VERSION : '0';
69 $previous = get_option(self::VERSION_OPTION, '');
70 if ($previous === $version) {
71 return;
72 }
73 update_option(self::VERSION_OPTION, $version, false);
74
75 if ($previous !== '' && $previous !== $version) {
76 $this->track('plugin_updated', array(
77 'from_version' => substr((string) $previous, 0, 20),
78 'to_version' => substr((string) $version, 0, 20),
79 ));
80 }
81
82 if (get_option(self::OPT_IN_OPTION) === 'yes' && get_option(self::EXPLICIT_CONSENT_OPTION) !== 'yes') {
83 update_option(self::OPT_IN_OPTION, 'no');
84 }
85 }
86
87 /**
88 * Action handler for metasync_analytics_event. Actions discard return
89 * values, so this wraps track() without one.
90 *
91 * @param mixed $event Event name.
92 * @param array $properties Event properties.
93 * @return void
94 */
95 public function handle_event($event, $properties = array()) {
96 $this->track($event, $properties);
97 }
98
99 /**
100 * Action handler for metasync_analytics_event_once; see handle_event().
101 *
102 * @param mixed $event Event name.
103 * @param array $properties Event properties.
104 * @param string $dedupe_key Distinguishes variants of the same event.
105 * @param int $ttl Window in seconds.
106 * @return void
107 */
108 public function handle_event_once($event, $properties = array(), $dedupe_key = '', $ttl = DAY_IN_SECONDS) {
109 $this->track_once($event, $properties, $dedupe_key, $ttl);
110 }
111
112 public function is_opted_in() {
113 return get_option(self::OPT_IN_OPTION, 'no') === 'yes';
114 }
115
116 /**
117 * Send one event.
118 *
119 * @param mixed $event Event name.
120 * @param array $properties Event properties.
121 * @param int|null $timestamp Unix time the event happened; defaults to now.
122 * Rollups flushed late pass the time their
123 * period started so counts land in that period.
124 * @return bool Whether the event was handed to the transport.
125 */
126 public function track($event, $properties = array(), $timestamp = null) {
127 if (!$this->is_opted_in() || !$this->is_configured() || !is_string($event) || $event === '') {
128 return false;
129 }
130
131 $batch = array(
132 array(
133 'event' => sanitize_key($event),
134 'properties' => $this->build_properties($properties),
135 'distinct_id' => $this->get_distinct_id(),
136 'timestamp' => gmdate('c', is_int($timestamp) && $timestamp > 0 ? $timestamp : time()),
137 ),
138 );
139 $group_identify = $this->get_due_group_identify();
140 if ($group_identify !== null) {
141 $batch[] = $group_identify;
142 }
143
144 $payload = array(
145 'api_key' => $this->get_api_key(),
146 'batch' => $batch,
147 );
148
149 wp_remote_post($this->get_host() . self::BATCH_PATH, array(
150 'body' => wp_json_encode($payload),
151 'headers' => array('Content-Type' => 'application/json'),
152 'timeout' => 2,
153 'blocking' => false,
154 'data_format' => 'body',
155 ));
156
157 return true;
158 }
159
160 /**
161 * Return the event payload for tests without sending it.
162 *
163 * @param mixed $event Event name.
164 * @param array $properties Event properties.
165 * @return array|null
166 */
167 public function get_event_payload($event, $properties = array()) {
168 if (!$this->is_opted_in() || !$this->is_configured() || !is_string($event) || $event === '') {
169 return null;
170 }
171 return array(
172 'event' => sanitize_key($event),
173 'properties' => $this->build_properties($properties),
174 'distinct_id' => $this->get_distinct_id(),
175 );
176 }
177
178 /**
179 * Send an event at most once per $ttl seconds for the same dedupe key.
180 *
181 * For signals that can repeat on every cron tick or retry (connection
182 * lost, the same import error over and over) where one event per window
183 * is all the analysis needs.
184 *
185 * @param mixed $event Event name.
186 * @param array $properties Event properties.
187 * @param string $dedupe_key Distinguishes variants of the same event.
188 * @param int $ttl Window in seconds.
189 * @return bool Whether the event was sent.
190 */
191 public function track_once($event, $properties = array(), $dedupe_key = '', $ttl = DAY_IN_SECONDS) {
192 if (!$this->is_opted_in() || !is_string($event) || $event === '') {
193 return false;
194 }
195 $key = self::ONCE_PREFIX . md5($event . '|' . (string) $dedupe_key);
196 if (false !== get_transient($key)) {
197 return false;
198 }
199 set_transient($key, 1, max(MINUTE_IN_SECONDS, (int) $ttl));
200 return $this->track($event, $properties);
201 }
202
203 /**
204 * Shared rollup: add counters to the current day/hour bucket of an event
205 * instead of sending one event per occurrence.
206 *
207 * When a bucket closes, its counters go out as one event — on the next
208 * increment, or from the flush cron if nothing else happens — with the
209 * counter names as properties plus `period`. Use this for anything that
210 * can fire from bulk work, platform pushes or repeated saves.
211 *
212 * @param mixed $event Event name.
213 * @param mixed $counters Counter name => amount to add.
214 * @param string $period 'day' or 'hour'.
215 * @return void
216 */
217 public function track_rollup($event, $counters, $period = 'day') {
218 if (!$this->is_opted_in() || !is_string($event) || $event === '' || !is_array($counters)) {
219 return;
220 }
221
222 $event = sanitize_key($event);
223 $period = $period === 'hour' ? 'hour' : 'day';
224 $bucket = $this->get_rollup_bucket($period);
225 $option = self::ROLLUP_PREFIX . $event;
226
227 // Platform pushes and the flush cron can hit the same row at once;
228 // without the lock one writer's counts overwrite the other's, or a
229 // closed bucket goes out twice. If the lock stays busy the increment
230 // is dropped: analytics must never stall the request it observes.
231 if (!$this->acquire_rollup_lock($event)) {
232 return;
233 }
234
235 $state = get_option($option, array());
236 if (!is_array($state)) {
237 $state = array();
238 }
239 if (!empty($state['bucket']) && $state['bucket'] !== $bucket) {
240 // Send first: resetting before a failed/no-op send would lose the counts.
241 $this->send_rollup($event, $state);
242 $state = array();
243 }
244
245 $counts = isset($state['counts']) && is_array($state['counts']) ? $state['counts'] : array();
246 foreach ($counters as $name => $amount) {
247 $name = sanitize_key((string) $name);
248 if ($name === '' || !is_numeric($amount)) {
249 continue;
250 }
251 $counts[$name] = ($counts[$name] ?? 0) + (int) $amount;
252 }
253
254 update_option($option, array(
255 'bucket' => $bucket,
256 'period' => $period,
257 'started_at' => (int) ($state['started_at'] ?? time()),
258 'counts' => $counts,
259 ), false);
260 $this->release_rollup_lock($event);
261
262 if (!wp_next_scheduled(self::ROLLUP_FLUSH_HOOK)) {
263 wp_schedule_single_event(time() + HOUR_IN_SECONDS, self::ROLLUP_FLUSH_HOOK);
264 }
265 }
266
267 /**
268 * Flush cron: send every rollup whose bucket has closed, so the last
269 * bucket of a quiet site is not held back until the next occurrence.
270 * Re-arms itself only while open buckets remain.
271 */
272 public function flush_rollups() {
273 global $wpdb;
274 if (empty($wpdb) || empty($wpdb->options)) {
275 return;
276 }
277
278 $names = $wpdb->get_col($wpdb->prepare(
279 "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- core table name; prefix scan of our own rollup rows.
280 $wpdb->esc_like(self::ROLLUP_PREFIX) . '%'
281 ));
282
283 $pending = false;
284 foreach ((array) $names as $option) {
285 $state = get_option($option, array());
286 $period = is_array($state) && ($state['period'] ?? '') === 'hour' ? 'hour' : 'day';
287 if (!$this->is_opted_in() || !is_array($state)) {
288 // Consent withdrawn (or a corrupt row): drop it unsent.
289 delete_option($option);
290 continue;
291 }
292 if (($state['bucket'] ?? '') === $this->get_rollup_bucket($period)) {
293 $pending = true;
294 continue;
295 }
296 $event = substr($option, strlen(self::ROLLUP_PREFIX));
297 if (!$this->acquire_rollup_lock($event)) {
298 $pending = true; // Busy: retry on the next flush.
299 continue;
300 }
301 // Re-read under the lock: an increment may have rolled the bucket
302 // (and sent it) since the read above.
303 wp_cache_delete($option, 'options');
304 $state = get_option($option, array());
305 if (is_array($state) && !empty($state['bucket'])) {
306 if ($state['bucket'] !== $this->get_rollup_bucket($period)) {
307 $this->send_rollup($event, $state);
308 delete_option($option);
309 } else {
310 $pending = true;
311 }
312 }
313 $this->release_rollup_lock($event);
314 }
315
316 if ($pending && !wp_next_scheduled(self::ROLLUP_FLUSH_HOOK)) {
317 wp_schedule_single_event(time() + HOUR_IN_SECONDS, self::ROLLUP_FLUSH_HOOK);
318 }
319 }
320
321 private function send_rollup($event, array $state) {
322 $counts = isset($state['counts']) && is_array($state['counts']) ? $state['counts'] : array();
323 if ($counts === array()) {
324 return;
325 }
326 $counts['period'] = ($state['period'] ?? '') === 'hour' ? 'hour' : 'day';
327 $started_at = (int) ($state['started_at'] ?? 0);
328 $this->track($event, $counts, $started_at > 0 ? $started_at : null);
329 }
330
331 /**
332 * Per-event mutex for rollup rows. INSERT IGNORE is atomic, unlike
333 * add_option() (which upserts); the same approach core uses for
334 * WP_Upgrader::create_lock(). A lock left by a dead request expires.
335 */
336 private function acquire_rollup_lock($event) {
337 global $wpdb;
338 if (empty($wpdb) || empty($wpdb->options)) {
339 return true;
340 }
341 $name = self::ROLLUP_LOCK_PREFIX . $event;
342 for ($attempt = 0; $attempt < 3; $attempt++) {
343 $inserted = $wpdb->query($wpdb->prepare(
344 "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- core table name; atomic lock row.
345 $name,
346 (string) time()
347 ));
348 if ($inserted) {
349 return true;
350 }
351 $since = $wpdb->get_var($wpdb->prepare(
352 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- core table name; lock row read.
353 $name
354 ));
355 if ($since !== null && time() - (int) $since > self::ROLLUP_LOCK_STALE_AFTER) {
356 // Delete only the stale value seen, so a fresh lock taken in
357 // between is not removed.
358 $wpdb->query($wpdb->prepare(
359 "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery -- core table name; stale lock cleanup.
360 $name,
361 (string) $since
362 ));
363 continue;
364 }
365 usleep(50000);
366 }
367 return false;
368 }
369
370 private function release_rollup_lock($event) {
371 global $wpdb;
372 if (empty($wpdb) || empty($wpdb->options)) {
373 return;
374 }
375 $wpdb->delete($wpdb->options, array('option_name' => self::ROLLUP_LOCK_PREFIX . $event)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- lock row release.
376 }
377
378 private function get_rollup_bucket($period) {
379 return gmdate($period === 'hour' ? 'YmdH' : 'Ymd');
380 }
381
382 /**
383 * MCP write tools: daily counts only, never per-event. An AI agent can do
384 * 100+ writes in one session, so per-event tracking would be pure noise.
385 * On the first write of a new day, yesterday's counts are flushed as one
386 * summary event and the option row is cleaned up.
387 */
388 public function track_mcp_tool($name, $params, $result, $execution_time) {
389 if (!$this->is_opted_in() || !$this->is_write_tool($name)) {
390 return;
391 }
392
393 $tool = sanitize_key(str_replace('wordpress_', '', (string) $name));
394 $day = gmdate('Ymd');
395
396 $count = get_option('metasync_ph_mcp_count_' . $day, array());
397 if (!is_array($count)) {
398 $count = array();
399 }
400 if ($count === array()) {
401 $this->flush_mcp_daily_summary(gmdate('Ymd', time() - 86400));
402 }
403
404 $count[$tool] = ($count[$tool] ?? 0) + 1;
405 update_option('metasync_ph_mcp_count_' . $day, $count, false);
406 }
407
408 private function flush_mcp_daily_summary($day) {
409 $counts = get_option('metasync_ph_mcp_count_' . $day, array());
410 // Send first: deleting before a failed/no-op send would lose the counts.
411 if (is_array($counts) && $counts !== array()) {
412 $this->track('mcp_daily_summary', array(
413 'tools_used' => count($counts),
414 'total_calls' => array_sum($counts),
415 ));
416 }
417 delete_option('metasync_ph_mcp_count_' . $day);
418 $this->sweep_stale_mcp_days(gmdate('Ymd'));
419 }
420
421 /**
422 * The rollover flush only targets yesterday. If the site skipped one or
423 * more days in between, those day rows would linger forever — drop every
424 * count row that is not today's.
425 */
426 private function sweep_stale_mcp_days($today) {
427 global $wpdb;
428 if (empty($wpdb) || empty($wpdb->options)) {
429 return;
430 }
431 $wpdb->query($wpdb->prepare(
432 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name != %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- core table name.
433 'metasync_ph_mcp_count_%',
434 'metasync_ph_mcp_count_' . $today
435 ));
436 }
437
438 /**
439 * Content Genius syncs: at most one event per action per hour, carrying
440 * the article counts accumulated since the previous send — so bulk syncs
441 * of dozens of posts stay at one event per hour with real counts.
442 */
443 public function track_content_genius_sync($content_type, $action, $is_landing_page) {
444 if (!$this->is_opted_in()) {
445 return;
446 }
447
448 $action = sanitize_key((string) $action);
449 $type = sanitize_key((string) $content_type);
450 $key = 'metasync_ph_cg_' . $action;
451
452 $state = get_option($key, array());
453 if (!is_array($state)) {
454 $state = array();
455 }
456 $counts = isset($state['counts']) && is_array($state['counts']) ? $state['counts'] : array();
457 $sent_at = (int) ($state['sent_at'] ?? 0);
458 $counts[$type] = ($counts[$type] ?? 0) + 1;
459
460 if (time() - $sent_at >= HOUR_IN_SECONDS) {
461 $this->track('content_genius_article_' . $action, array(
462 'count' => array_sum($counts),
463 'content_type' => $type,
464 'is_landing_page' => (bool) $is_landing_page,
465 ));
466 update_option($key, array('counts' => array(), 'sent_at' => time()), false);
467 } else {
468 update_option($key, array('counts' => $counts, 'sent_at' => $sent_at), false);
469 }
470 }
471
472 /**
473 * SEO data imports from other plugins: one event per finished import.
474 *
475 * Post-level imports run as 50-item AJAX batches, so per-batch counts are
476 * summed in a short-lived transient (reset by the offset-0 batch) and the
477 * event goes out only on the final batch or the first failure. Single-shot
478 * imports (redirects, sitemap, robots, schema, CSV) send immediately.
479 *
480 * @param string $source_plugin Plugin imported from (yoast, rankmath, csv, ...).
481 * @param string $data_type What was imported.
482 * @param mixed $result The importer's result array.
483 * @param int $offset Batch offset; 0 starts a new import.
484 * @param mixed $extra Extra properties (e.g. overwrite_existing).
485 */
486 public function track_import_batch($source_plugin, $data_type, $result, $offset = 0, $extra = array()) {
487 if (!$this->is_opted_in() || !is_array($result)) {
488 return;
489 }
490
491 $source_plugin = sanitize_key((string) $source_plugin);
492 $data_type = sanitize_key((string) $data_type);
493 $success = !empty($result['success']);
494 $breakdown = isset($result['breakdown']) && is_array($result['breakdown']) ? $result['breakdown'] : array();
495 $imported = (int) ($result['imported'] ?? ($breakdown['imported'] ?? 0));
496 $skipped = (int) ($result['skipped'] ?? 0);
497
498 if (array_key_exists('is_complete', $result) || array_key_exists('has_more', $result)) {
499 $key = 'metasync_ph_import_' . md5(get_current_user_id() . '|' . $source_plugin . '|' . $data_type);
500 $sum = (int) $offset === 0 ? array() : get_transient($key);
501 if (!is_array($sum)) {
502 $sum = array();
503 }
504 $imported += (int) ($sum['imported'] ?? 0);
505 $skipped += (int) ($sum['skipped'] ?? 0);
506 $complete = array_key_exists('is_complete', $result) ? !empty($result['is_complete']) : empty($result['has_more']);
507 if ($success && !$complete) {
508 // A day, not an hour: big sites can run imports for longer, and the
509 // offset-0 batch resets the sum anyway.
510 set_transient($key, array('imported' => $imported, 'skipped' => $skipped), DAY_IN_SECONDS);
511 return;
512 }
513 delete_transient($key);
514 }
515
516 $this->track('seo_data_import_completed', array_merge(is_array($extra) ? $extra : array(), array(
517 'source_plugin' => $source_plugin,
518 'data_type' => $data_type,
519 'success' => $success,
520 'imported' => $imported,
521 'skipped' => $skipped,
522 'total' => isset($result['total']) ? (int) $result['total'] : $imported + $skipped,
523 )));
524 }
525
526 /**
527 * REST: single deliberate write actions only. Reads are not tracked, and
528 * Content Genius create/update syncs are covered by the batched
529 * content_genius event instead. Platform-side actions can burst, so each
530 * endpoint reports at most once per hour.
531 *
532 * The filter receives the raw callback return value (mixed) before core
533 * normalizes it, so anything that is not a WP_REST_Response is skipped —
534 * several handlers return false, arrays, WP_Error, or die via
535 * wp_send_json*, and none of those shapes can be read safely.
536 */
537 public function track_rest_request($response, $handler, $request) {
538 if (!$this->is_opted_in() || is_wp_error($response) || !$response instanceof WP_REST_Response) {
539 return $response;
540 }
541
542 $method = strtoupper((string) $request->get_method());
543 if ($method === 'GET') {
544 return $response;
545 }
546
547 $endpoint = $this->get_tracked_rest_endpoint((string) $request->get_route());
548 if ($endpoint !== null) {
549 $dedupe_key = 'metasync_ph_rest_' . $endpoint;
550 if (false === get_transient($dedupe_key)) {
551 $this->track('rest_endpoint_called', array(
552 'endpoint' => $endpoint,
553 'method' => $method,
554 'status' => (int) $response->get_status(),
555 ));
556 set_transient($dedupe_key, 1, HOUR_IN_SECONDS);
557 }
558 }
559
560 return $response;
561 }
562
563 /**
564 * Helper for explicit feature events from anywhere in the plugin.
565 *
566 * @param string $event Event name.
567 * @param array $properties Event properties.
568 * @return void
569 */
570 public static function feature($event, $properties = array()) {
571 self::dispatch('metasync_analytics_event', $event, $properties);
572 }
573
574 /**
575 * Helper for deduplicated events: at most one per $ttl per dedupe key.
576 */
577 public static function feature_once($event, $properties = array(), $dedupe_key = '', $ttl = DAY_IN_SECONDS) {
578 self::dispatch('metasync_analytics_event_once', $event, $properties, $dedupe_key, $ttl);
579 }
580
581 /**
582 * Helper for rollups: counters summed per day/hour, sent once per period.
583 */
584 public static function rollup($event, $counters, $period = 'day') {
585 self::dispatch('metasync_analytics_rollup', $event, $counters, $period);
586 }
587
588 /**
589 * Helper for SEO data import batches; sends once per finished import.
590 */
591 public static function import_batch($source_plugin, $data_type, $result, $offset = 0, $extra = array()) {
592 self::dispatch('metasync_analytics_import_batch', $source_plugin, $data_type, $result, $offset, $extra);
593 }
594
595 /**
596 * Helper for Content Genius sync rollups.
597 */
598 public static function content_genius_sync($content_type, $action, $is_landing_page) {
599 self::dispatch('metasync_content_genius_sync', $content_type, $action, $is_landing_page);
600 }
601
602 /**
603 * Fire a helper hook. Analytics must never break the feature it
604 * observes, so a context without the hook API (CLI tools, unit harnesses)
605 * simply drops the event.
606 */
607 private static function dispatch($hook, ...$args) {
608 if (function_exists('do_action')) {
609 do_action($hook, ...$args);
610 }
611 }
612
613 private function is_configured() {
614 return $this->get_api_key() !== '';
615 }
616
617 private function get_api_key() {
618 return defined('METASYNC_POSTHOG_API_KEY') ? trim((string) METASYNC_POSTHOG_API_KEY) : '';
619 }
620
621 private function get_host() {
622 $host = defined('METASYNC_POSTHOG_HOST') ? (string) constant('METASYNC_POSTHOG_HOST') : '';
623 if ($host === '') {
624 $host = self::DEFAULT_HOST;
625 }
626 return untrailingslashit(esc_url_raw($host));
627 }
628
629 private function get_distinct_id() {
630 $user_id = get_current_user_id();
631 $site_url = (string) get_option('siteurl', home_url('/'));
632 return 'user_' . wp_hash($user_id . '|' . $site_url);
633 }
634
635 /**
636 * Site identity shared by every event, whoever sent it.
637 *
638 * Platform pushes and cron run as user 0, so their distinct_id differs
639 * from the admin's and person-level funnels cannot join them. Every event
640 * therefore also carries a PostHog `site` group, keyed by a hash of the
641 * site URL (stable across salt rotation, unlike wp_hash()).
642 */
643 private function get_site_group_key() {
644 $site_url = strtolower(untrailingslashit((string) get_option('siteurl', home_url('/'))));
645 return 'site_' . substr(hash('sha256', $site_url), 0, 32);
646 }
647
648 private function build_properties($properties) {
649 $built = array_merge($this->get_super_properties(), $this->sanitize_properties($properties));
650 // Added after sanitizing: sanitize_key() would strip the `$`.
651 $built['$groups'] = array(self::GROUP_TYPE => $this->get_site_group_key());
652 return $built;
653 }
654
655 /**
656 * A `$groupidentify` entry setting the site group's properties, at most
657 * once per day and again whenever the plugin version changes; null when
658 * one has already gone out.
659 */
660 private function get_due_group_identify() {
661 $stamp = (defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown') . '|' . gmdate('Ymd');
662 if (get_option(self::GROUP_IDENTIFIED_OPTION, '') === $stamp) {
663 return null;
664 }
665 update_option(self::GROUP_IDENTIFIED_OPTION, $stamp, false);
666
667 $super = $this->get_super_properties();
668 unset($super['user_role']);
669 $group_key = $this->get_site_group_key();
670 $host = wp_parse_url(home_url('/'), PHP_URL_HOST);
671
672 return array(
673 'event' => '$groupidentify',
674 'properties' => array(
675 '$group_type' => self::GROUP_TYPE,
676 '$group_key' => $group_key,
677 '$group_set' => array_merge($super, array('name' => is_string($host) ? $host : $group_key)),
678 ),
679 'distinct_id' => $group_key,
680 'timestamp' => gmdate('c'),
681 );
682 }
683
684 private function get_super_properties() {
685 global $wp_version;
686 return array(
687 'site_url' => esc_url_raw(home_url('/')),
688 'plugin_version' => defined('METASYNC_VERSION') ? METASYNC_VERSION : 'unknown',
689 'wp_version' => (string) $wp_version,
690 'php_version' => PHP_VERSION,
691 'is_multisite' => is_multisite(),
692 'user_role' => $this->get_user_role(),
693 );
694 }
695
696 private function get_user_role() {
697 $user = wp_get_current_user();
698 return !empty($user->roles) ? sanitize_key((string) reset($user->roles)) : 'guest';
699 }
700
701 private function sanitize_properties($properties) {
702 if (!is_array($properties)) {
703 return array();
704 }
705
706 $safe = array();
707 foreach ($properties as $key => $value) {
708 $key = sanitize_key($key);
709 if ($key === '' || in_array($key, array('site_url', 'api_key', 'password', 'email', 'name', 'content', 'url', 'token', 'uuid'), true)) {
710 continue;
711 }
712 if (is_bool($value) || is_int($value) || is_float($value)) {
713 $safe[$key] = $value;
714 } elseif (is_string($value) && strlen($value) <= 100) {
715 $safe[$key] = sanitize_text_field($value);
716 }
717 }
718 return $safe;
719 }
720
721 private function is_write_tool($name) {
722 $name = strtolower((string) $name);
723 if ($name === '') {
724 return false;
725 }
726 foreach (array('get_', 'list_', 'search_', 'analyze_', 'check_', 'validate_', 'audit_', 'db_', 'system_') as $prefix) {
727 if (strpos($name, $prefix) !== false) {
728 return false;
729 }
730 }
731 return (bool) preg_match('/(^|_)(create|update|delete|set|add|remove|bulk|import|convert|regenerate|trigger|purge|sync|upload|generate|restore|publish|exclude|clear)/', $name);
732 }
733
734 private function get_tracked_rest_endpoint($route) {
735 $write_routes = array('createKeyFile', 'syncHeartbeatData');
736 foreach ($write_routes as $endpoint) {
737 if (stripos($route, $endpoint) !== false) {
738 return sanitize_key($endpoint);
739 }
740 }
741 return null;
742 }
743 }
744