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 ); } }