# woocommerce-pos/1.10.17/includes/Services/Analytics.php

WCPOS – Point of Sale (POS) plugin for WooCommerce, version 1.10.17. 469 lines.

- Page: https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/code/includes/Services/Analytics.php
- Raw: https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/raw/includes/Services/Analytics.php
- Modified: 2026-09-06T10:00:12+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/woocommerce-pos/1.10.17/code/includes/Services/Analytics.php#L10-L20`.

```php
<?php
/**
 * Analytics service.
 *
 * Thin wrapper around the PostHog capture API. Sends anonymous product
 * analytics so the WCPOS team can understand how the plugin is used and
 * make better product decisions.
 *
 * Events are only sent when the user has explicitly opted in via the
 * `tracking_consent` setting. All calls are no-ops otherwise, so callers
 * can invoke them unconditionally.
 *
 * @package WCPOS\WooCommercePOS\Services
 */

namespace WCPOS\WooCommercePOS\Services;

use WCPOS\WooCommercePOS\Services\Settings;
use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
use WP_User;
use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;

/**
 * Analytics service class.
 */
class Analytics {
	/**
	 * Default PostHog project token.
	 *
	 * Client-side PostHog project tokens are designed to be public. They
	 * authorize event ingestion into a specific project only.
	 *
	 * Override with the `WCPOS_POSTHOG_TOKEN` constant if needed.
	 *
	 * @var string
	 */
	const DEFAULT_TOKEN = 'phc_BhTJzZ7fXMqcD4MiaUJQsQqPkEpu94yoSAthXFBWemvd';

	/**
	 * Default PostHog ingestion host.
	 *
	 * Uses a reverse proxy on wcpos.com to reduce the chance of being
	 * blocked by privacy tooling. Override with `WCPOS_POSTHOG_HOST`.
	 *
	 * @var string
	 */
	const DEFAULT_HOST = 'https://ph.wcpos.com';

	/**
	 * Capture endpoint path.
	 *
	 * @var string
	 */
	const CAPTURE_PATH = '/capture/';

	/**
	 * De-dup window for impressions on AMBIENT upsell placements.
	 *
	 * An ambient placement renders as a side effect of unrelated work — the
	 * product editor, the plugins list — so a merchant re-arms a daily window
	 * simply by doing their job. Live data made the cost obvious: with a daily
	 * window `product_edit_price` alone logged 40,362 impressions from 414
	 * users (~97 each), and `upgrade_cta_viewed` grew to 90% of every event the
	 * project holds. That does not measure interest, it measures how often
	 * someone edits products, and it makes view -> click conversion meaningless
	 * (0.015% on that placement).
	 *
	 * A month still answers "was this CTA on screen for this merchant", which
	 * is the only question the upgrade funnel asks of an impression.
	 *
	 * Navigational placements — a settings tab, the landing page — keep the
	 * shorter default: the merchant chose to go there, so the visit is signal.
	 *
	 * @var int
	 */
	const AMBIENT_IMPRESSION_TTL = MONTH_IN_SECONDS;

	/**
	 * HTTP request timeout in seconds.
	 *
	 * Kept low because capture is fire-and-forget. We set
	 * `blocking => false` in practice, but the timeout still applies to
	 * the TCP connect step.
	 *
	 * @var float
	 */
	const REQUEST_TIMEOUT = 2.0;

	/**
	 * Singleton instance.
	 *
	 * @var null|self
	 */
	private static $instance = null;

	/**
	 * Cached consent state for the current request.
	 *
	 * @var null|bool
	 */
	private $enabled_cache = null;

	/**
	 * Get the singleton instance.
	 */
	public static function instance(): self {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	/**
	 * Reset the singleton. Intended for tests only.
	 */
	public static function reset_instance(): void {
		self::$instance = null;
	}

	/**
	 * Whether analytics is enabled for the current site.
	 *
	 * Returns true only when the user has explicitly allowed tracking
	 * via the general settings. Cached for the duration of the request.
	 */
	public function is_enabled(): bool {
		if ( null !== $this->enabled_cache ) {
			return $this->enabled_cache;
		}

		// The PERSISTED consent, not the filtered read view — otherwise any
		// plugin filtering woocommerce_pos_general_settings could switch
		// telemetry on for a merchant who declined it. Same gate as
		// Services\Error_Reporter.
		$consent             = Settings::instance()->raw_tracking_consent();
		$this->enabled_cache = ( 'allowed' === $consent );

		return $this->enabled_cache;
	}

	/**
	 * Clear the cached consent state.
	 *
	 * Useful after programmatically changing the consent value within a
	 * single request (for example, the AJAX consent notice handler).
	 */
	public function clear_consent_cache(): void {
		$this->enabled_cache = null;
	}

	/**
	 * Capture an event.
	 *
	 * No-op unless analytics is enabled. Automatically attaches the
	 * current user's UUID as `distinct_id`, groups the event under the
	 * site UUID, and merges in a small set of default context properties.
	 *
	 * @param string $event               Event name, e.g. `pro_link_clicked`.
	 * @param array  $properties          Event properties. Caller-supplied values
	 *                                    take precedence over defaults.
	 * @param string $distinct_id_override Identity to attribute the event to.
	 *                                    Defaults to the current user's UUID.
	 *                                    Used by group identification and by
	 *                                    scheduled events, which run without a
	 *                                    logged-in user.
	 * @param string $timestamp           ISO-8601 event time. Defaults to now.
	 *                                    Set it when reporting something that
	 *                                    happened earlier — an install event
	 *                                    held back until consent was granted
	 *                                    must keep its real install date or the
	 *                                    retention cohorts are wrong.
	 *
	 * @return bool True when a request was dispatched, false otherwise.
	 */
	public function capture( string $event, array $properties = array(), string $distinct_id_override = '', string $timestamp = '' ): bool {
		if ( ! $this->is_enabled() ) {
			return false;
		}

		if ( '' === $event ) {
			return false;
		}

		$distinct_id = '' !== $distinct_id_override ? $distinct_id_override : $this->get_distinct_id();
		if ( '' === $distinct_id ) {
			return false;
		}

		$merged_properties = array_merge( $this->get_default_properties(), $properties );

		// PostHog reserves $identify / $groupidentify for person / group
		// definitions. Auto-attaching a $groups binding to those would
		// either duplicate the event's own $group_type/$group_key or
		// incorrectly cross-link them to an unrelated group, so only
		// attach $groups to regular events.
		if ( ! $this->is_reserved_event( $event ) ) {
			$site_id = $this->get_site_id();
			if ( '' !== $site_id ) {
				$merged_properties['$groups'] = array( 'site' => $site_id );
			}
		}

		$payload = array(
			'api_key'     => $this->get_token(),
			'event'       => $event,
			'distinct_id' => $distinct_id,
			'properties'  => $merged_properties,
			'timestamp'   => '' !== $timestamp ? $timestamp : gmdate( 'c' ),
		);

		return $this->send( self::CAPTURE_PATH, $payload );
	}

	/**
	 * Capture an impression-style event at most once per de-dup window.
	 *
	 * Impression events such as upgrade CTA views can otherwise fire on
	 * every page render — a persistent admin link or a product-edit upsell
	 * field would emit hundreds of identical events per user, drowning the
	 * funnel and inflating ingestion. This de-duplicates per current user +
	 * key using a short-lived transient, so each impression slot is counted
	 * at most once per window.
	 *
	 * @param string $event      Event name, e.g. `upgrade_cta_viewed`.
	 * @param array  $properties Event properties.
	 * @param string $dedup_key  Stable key for the impression slot (for
	 *                           example, the placement). Combined with the
	 *                           event name and current user UUID to form the
	 *                           transient key.
	 * @param int    $ttl        De-dup window in seconds. Defaults to a day.
	 *
	 * @return bool True when an event was dispatched, false when suppressed
	 *              or analytics is disabled.
	 */
	public function capture_once( string $event, array $properties = array(), string $dedup_key = '', int $ttl = DAY_IN_SECONDS ): bool {
		if ( ! $this->is_enabled() ) {
			return false;
		}

		$distinct_id = $this->get_distinct_id();
		if ( '' === $distinct_id ) {
			return false;
		}

		$transient_key = 'wcpos_imp_' . md5( $distinct_id . '|' . $event . '|' . $dedup_key );
		if ( false !== get_transient( $transient_key ) ) {
			return false;
		}

		$dispatched = $this->capture( $event, $properties );

		// Only record the de-dup marker once the event actually dispatched, so
		// a transient network failure does not permanently suppress the slot.
		if ( $dispatched ) {
			set_transient( $transient_key, 1, $ttl );
		}

		return $dispatched;
	}

	/**
	 * Set person properties on the current user.
	 *
	 * Uses the PostHog `$identify` event. Properties set via `$set_once`
	 * only apply the first time they are seen.
	 *
	 * @param array $set      Properties to set (overwrite).
	 * @param array $set_once Properties to set only on first sighting.
	 */
	public function identify( array $set = array(), array $set_once = array() ): bool {
		if ( ! $this->is_enabled() ) {
			return false;
		}

		$properties = array();
		if ( ! empty( $set ) ) {
			$properties['$set'] = $set;
		}
		if ( ! empty( $set_once ) ) {
			$properties['$set_once'] = $set_once;
		}

		return $this->capture( '$identify', $properties );
	}

	/**
	 * Set group properties.
	 *
	 * Uses the PostHog `$groupidentify` event. Every plugin install maps
	 * to a single `site` group keyed by the site UUID.
	 *
	 * @param string $group_type Group type, e.g. `site`.
	 * @param string $group_key  Group key, e.g. the site UUID.
	 * @param array  $properties Group properties.
	 */
	public function group( string $group_type, string $group_key, array $properties = array() ): bool {
		if ( ! $this->is_enabled() ) {
			return false;
		}

		if ( '' === $group_type || '' === $group_key ) {
			return false;
		}

		// A group identification describes the site, not a person. When no user
		// is logged in — the scheduled property refresh runs from cron — fall
		// back to PostHog's own convention of keying the event by the group
		// itself, so the refresh is not silently dropped for want of an identity.
		$distinct_id = $this->get_distinct_id();
		if ( '' === $distinct_id ) {
			$distinct_id = $group_type . '_' . $group_key;
		}

		return $this->capture(
			'$groupidentify',
			array(
				'$group_type' => $group_type,
				'$group_key'  => $group_key,
				'$group_set'  => $properties,
			),
			$distinct_id
		);
	}

	/**
	 * Get the PostHog project token.
	 *
	 * Allows override via constant (`WCPOS_POSTHOG_TOKEN`) or filter
	 * (`woocommerce_pos_posthog_token`) for self-hosted deployments.
	 */
	public function get_token(): string {
		$token = \defined( 'WCPOS_POSTHOG_TOKEN' ) ? (string) \WCPOS_POSTHOG_TOKEN : self::DEFAULT_TOKEN;

		/**
		 * Filters the PostHog project token used for analytics.
		 *
		 * @since 1.8.14
		 *
		 * @param string $token The default project token.
		 */
		return (string) apply_filters( 'woocommerce_pos_posthog_token', $token );
	}

	/**
	 * Get the PostHog host URL.
	 *
	 * Allows override via constant (`WCPOS_POSTHOG_HOST`) or filter
	 * (`woocommerce_pos_posthog_host`).
	 */
	public function get_host(): string {
		$host = \defined( 'WCPOS_POSTHOG_HOST' ) ? (string) \WCPOS_POSTHOG_HOST : self::DEFAULT_HOST;

		/**
		 * Filters the PostHog host URL used for analytics.
		 *
		 * @since 1.8.14
		 *
		 * @param string $host The default host URL.
		 */
		return untrailingslashit( (string) apply_filters( 'woocommerce_pos_posthog_host', $host ) );
	}

	/**
	 * Get the distinct ID for the current user.
	 *
	 * Delegates to Pos_Uuid — the sole authority for `_woocommerce_pos_uuid` — so
	 * analytics events carry the SAME identity the /cashier and /customers
	 * endpoints serve, lazily provisioning it for admin-only installs (where the
	 * POS frontend has never loaded).
	 *
	 * Empty string when no user is logged in.
	 */
	public function get_distinct_id(): string {
		$user = wp_get_current_user();
		if ( ! $user instanceof WP_User || 0 === $user->ID ) {
			return '';
		}

		return Pos_Uuid::ensure_user_uuid( $user );
	}

	/**
	 * Get the site UUID used as the `site` group key.
	 *
	 * Lazily provisions the site UUID if missing so admin-only
	 * installs (fresh plugin activation, no POS frontend load yet)
	 * still have a stable site identifier for grouping.
	 */
	public function get_site_id(): string {
		// The deactivation hook runs even when Activator::init() bailed on the
		// WooCommerce check — in that request `new Init()` never ran, so
		// wcpos-functions.php is not loaded and the helper does not exist.
		// Read the option directly rather than fataling; an install that has
		// ever run properly already has one, and a site that has not is not
		// worth provisioning an identity for on its way out.
		if ( ! \function_exists( 'wcpos_get_site_uuid' ) ) {
			$uuid = get_option( 'woocommerce_pos_uuid', '' );

			return \is_string( $uuid ) ? $uuid : '';
		}

		return wcpos_get_site_uuid();
	}

	/**
	 * Whether the given event name is a PostHog-reserved identifier
	 * event that should not have a `$groups` binding auto-attached.
	 *
	 * @param string $event Event name.
	 */
	private function is_reserved_event( string $event ): bool {
		return '$identify' === $event || '$groupidentify' === $event;
	}

	/**
	 * Get default properties attached to every captured event.
	 */
	private function get_default_properties(): array {
		return array(
			'plugin_version' => PLUGIN_VERSION,
			'pro_active'     => $this->is_pro_active(),
			'locale'         => get_locale(),
		);
	}

	/**
	 * Whether the Pro plugin is active, safe to call before Init has run.
	 *
	 * Same situation as get_site_id(): the deactivation hook can fire in a
	 * request where the WooCommerce check failed, Init never ran, and
	 * wcpos-functions.php is not loaded. Fall back to the constant the helper
	 * itself reads rather than fataling on the way out.
	 */
	private function is_pro_active(): bool {
		if ( ! \function_exists( 'wcpos_is_pro_active' ) ) {
			return \defined( 'WCPOS\WooCommercePOSPro\VERSION' );
		}

		return wcpos_is_pro_active();
	}

	/**
	 * Dispatch a non-blocking HTTPS POST to the PostHog ingestion host.
	 *
	 * @param string $path    Endpoint path (e.g. /capture/).
	 * @param array  $payload JSON payload.
	 */
	private function send( string $path, array $payload ): bool {
		$url    = $this->get_host() . $path;
		$body   = wp_json_encode( $payload );
		if ( false === $body ) {
			return false;
		}

		$response = wp_remote_post(
			$url,
			array(
				'blocking' => false,
				'timeout'  => self::REQUEST_TIMEOUT,
				'headers'  => array( 'Content-Type' => 'application/json' ),
				'body'     => $body,
			)
		);

		return ! is_wp_error( $response );
	}
}

```
