record( 'wcpos_installed' ); } /** * Record a version upgrade. * * @param string $from_version Version being upgraded from. * @param string $to_version Version being upgraded to. */ public function record_upgrade( string $from_version, string $to_version ): void { // A fresh install runs the upgrade path with no previous version. That // is an install, and it has already been reported as one. if ( '' === $from_version || '0' === $from_version ) { return; } $this->record( 'wcpos_upgraded', array( 'from_version' => $from_version, 'to_version' => $to_version, ) ); } /** * Report that the POS app was opened. * * The activation step no admin-side signal can see. Recorded from the POS * template render rather than by tracking the menu link, so a bookmark, a * direct URL or a till that never visits wp-admin all count — and so it * counts opens rather than clicks that may never arrive. * * De-duplicated per user per day. A till is reloaded constantly; without a * window this would repeat the mistake that made `upgrade_cta_viewed` 90% of * the dataset. A day is also the useful unit: it makes this a daily-active * signal rather than a page-load counter. * * The site's first open is flagged rather than given its own event name, so * activation and engagement come off one series. */ public function report_app_opened(): void { // Latch the first open BEFORE the consent check, and never transmit it // from here — it is a local option, nothing leaves the site. Latching // only for consenting sites would mean a store that used the POS for a // month and then said yes would have its next open reported as its // first, which is untrue. This way an unknown first open stays unknown // rather than becoming a wrong one. // // See LATCH_VALUE: the constant is what makes this a safe claim. // Autoloaded because it is read on every POS open, and it is one byte. $is_first_open = add_option( self::FIRST_OPEN_OPTION, self::LATCH_VALUE, '', true ); $analytics = Analytics::instance(); if ( ! $analytics->is_enabled() ) { return; } $analytics->capture_once( 'pos_app_opened', array( 'is_first_open' => $is_first_open ), 'pos_app_opened' ); // A POS-only store may never load a wp-admin page, and admin_init is // where the queue is normally drained. Without this, events recorded // before consent — the install, the first sale — would sit unsent // forever on exactly the stores that use the product most. $this->flush_pending(); } /** * Record that the consent prompt was shown. * * This is the one event whose subject has not consented yet — by * definition, since the prompt only renders while the answer is undecided. * So it is queued, never sent, and reaches PostHog only if the user goes on * to say yes. Somebody who declines or ignores the prompt transmits * nothing, which is the only honest reading of what they were asked. * * The consequence, stated plainly because it limits what the data can * answer: we see views only for people who accepted, so a true acceptance * RATE is not computable from plugin telemetry and never will be. What this * does answer is which surface converted and how long the decision took. * For the rate, compare consenting sites against the public wordpress.org * active-install count — no extra collection required. * * @param string $surface Where the prompt was shown: `modal` or `callout`. */ public function record_consent_prompt_viewed( string $surface ): void { // The prompt re-renders on every allowed admin screen until the user // answers, so record the first sighting only. foreach ( (array) get_option( self::PENDING_OPTION, array() ) as $entry ) { if ( \is_array( $entry ) && 'consent_notice_viewed' === ( $entry['event'] ?? '' ) ) { return; } } $this->record( 'consent_notice_viewed', array( 'surface' => $surface ) ); } /** * Report that consent was granted. * * Sent immediately: the user has just said yes, so the gate is open. Also * flushes anything queued while they were deciding, rather than leaving it * for the next admin page load. * * There is deliberately no counterpart for "declined" or "dismissed". * Reporting that someone refused telemetry, by sending telemetry, is the * one thing this surface must never do. * * No surface is recorded: the server cannot tell which prompt the user * answered in — both can be on screen — and the paired * `consent_notice_viewed` already carries the surface that was shown. */ public function report_consent_granted(): void { $analytics = Analytics::instance(); // The choice was written in this request; the cached answer predates it. $analytics->clear_consent_cache(); if ( ! $analytics->is_enabled() ) { return; } $installed_at = (int) get_option( 'woocommerce_pos_installed_at', 0 ); $properties = array(); if ( $installed_at > 0 ) { $properties['days_since_install'] = max( 0, (int) floor( ( time() - $installed_at ) / DAY_IN_SECONDS ) ); } $analytics->capture( 'consent_notice_accepted', $properties ); // Send what was held back while the answer was pending. $this->flush_pending(); } /** * Report the deactivation event. Called from the deactivation hook. * * Reported immediately rather than queued: a deactivated plugin never gets * another admin page load to flush from, so an unsent deactivation would * sit in the queue until the user reactivates, by which point it is a lie. */ public function report_deactivation(): void { // A network-wide deactivation walks every blog in one request, and // Analytics caches the consent answer for the request. Without this the // first blog's "yes" would be reused for blogs that said no. Analytics::instance()->clear_consent_cache(); // Check consent before gathering anything: the churn properties run // store queries, and a site that opted out should not pay for them. if ( ! Analytics::instance()->is_enabled() ) { return; } $analytics = Analytics::instance(); // `wp plugin deactivate` runs with no current user, so get_distinct_id() // comes back empty and capture() would drop the event. Fall back to the // site identity, the same way the group refresh and the uninstall // reporter do. $distinct_id = $analytics->get_distinct_id(); if ( '' === $distinct_id ) { $site_id = $analytics->get_site_id(); if ( '' === $site_id ) { return; } $distinct_id = 'site_' . $site_id; } $analytics->capture( 'wcpos_deactivated', $this->get_churn_properties(), $distinct_id ); } /** * Throw away anything queued while the answer was pending. * * Called when the user declines, so the refusal takes effect in the request * that records it rather than whenever an admin page next happens to load. */ public function discard_pending(): void { delete_option( self::PENDING_OPTION ); } /** * Send any events recorded before consent was decided. * * Gated on consent first so that a site which has not opted in never pays * for the queue lookup. */ public function flush_pending(): void { if ( ! Analytics::instance()->is_enabled() ) { // A queued event outlives an undecided answer. Once the answer is // no, drop it rather than leaving it to sit in the options table // waiting for a consent that is not coming. if ( 'denied' === Settings::instance()->tracking_consent() ) { delete_option( self::PENDING_OPTION ); } return; } $pending = get_option( self::PENDING_OPTION ); if ( empty( $pending ) || ! \is_array( $pending ) ) { return; } // Clear first. A failed send is not worth retrying forever, and leaving // the queue populated would re-send on every admin page load. delete_option( self::PENDING_OPTION ); $analytics = Analytics::instance(); foreach ( $pending as $entry ) { if ( ! \is_array( $entry ) || empty( $entry['event'] ) ) { continue; } $analytics->capture( (string) $entry['event'], \is_array( $entry['properties'] ?? null ) ? $entry['properties'] : array(), '', isset( $entry['timestamp'] ) ? (string) $entry['timestamp'] : '' ); } // Queued events describe the install, so the site profile that goes with // them is worth sending in the same pass. $this->refresh_group_properties(); } /** * Schedule the daily group property refresh if consent allows it. */ public function maybe_schedule_refresh(): void { if ( ! Analytics::instance()->is_enabled() ) { // Consent can be withdrawn — stop refreshing if it has been. Guarded // so an opted-out site does not touch the cron array on every load. if ( wp_next_scheduled( self::REFRESH_HOOK ) ) { $this->clear_schedule(); } return; } if ( ! wp_next_scheduled( self::REFRESH_HOOK ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::REFRESH_HOOK ); } } /** * Clear the scheduled refresh. */ public function clear_schedule(): void { wp_clear_scheduled_hook( self::REFRESH_HOOK ); } /** * Transient guarding the on-page-load group refresh. * * @var string */ const REFRESH_THROTTLE_TRANSIENT = 'wcpos_analytics_group_refreshed'; /** * Refresh the site profile from a page load, at most once a day. * * The scheduled refresh is the primary path; this is the fallback for * installs where WP-Cron is unreliable or disabled. Throttled because the * profile is a slow-moving description of the site, not a page-view metric. */ public function maybe_refresh_group_properties(): void { if ( ! Analytics::instance()->is_enabled() ) { return; } if ( false !== get_transient( self::REFRESH_THROTTLE_TRANSIENT ) ) { return; } set_transient( self::REFRESH_THROTTLE_TRANSIENT, 1, DAY_IN_SECONDS ); $this->refresh_group_properties(); } /** * Push the current site profile onto the PostHog `site` group. */ public function refresh_group_properties(): void { $analytics = Analytics::instance(); $site_id = $analytics->get_site_id(); if ( '' === $site_id ) { return; } $properties = ( new Analytics_Profile() )->get_group_properties(); // Leave the band somewhere uninstall.php can read it without the plugin. if ( isset( $properties['order_count_band'] ) ) { update_option( self::LAST_ORDER_BAND_OPTION, $properties['order_count_band'], false ); } $analytics->group( 'site', $site_id, $properties ); } /** * Properties describing how much the site had invested when it churned. * * The order count is banded like every other count we report. Churn * analysis only asks whether they left with nothing or left with a real * trading history, and a band answers that without carrying an exact * figure out of the store. * * @return array */ private function get_churn_properties(): array { $metrics = ( new Landing_Profile() )->get_metrics(); return array( 'days_since_install' => (int) ( $metrics['days_since_install'] ?? 0 ), 'order_count_band' => Analytics_Profile::band( (int) ( $metrics['order_count'] ?? 0 ) ), ); } /** * Queue a lifecycle event for the next admin page load. * * Always queued, never sent inline — and that is not just about consent. * Install and upgrade both run before the plugin is fully booted: the * activation hook fires in a request where `plugins_loaded` has already * passed, so Init never ran and `wcpos-functions.php` is not loaded, and * the upgrade check runs before `new Init()`. Capturing from either point * would call `wcpos_get_site_uuid()` before it exists and fatal the * activation. Queueing needs nothing but the options API. * * The event carries only its own properties. The environment and store * snapshot lives on the `site` group, which flush_pending() refreshes in * the same pass — no need to copy it onto every event. * * @param string $event Event name. * @param array $properties Event properties. */ private function record( string $event, array $properties = array() ): void { // An explicit "no" is an answer, not a delay. if ( 'denied' === Settings::instance()->tracking_consent() ) { return; } $pending = get_option( self::PENDING_OPTION ); if ( ! \is_array( $pending ) ) { $pending = array(); } if ( \count( $pending ) >= self::MAX_PENDING ) { return; } $pending[] = array( 'event' => $event, 'properties' => $properties, 'timestamp' => gmdate( 'c' ), ); update_option( self::PENDING_OPTION, $pending, false ); } }