PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.0
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.0
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Services / Analytics.php

Analytics.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.0, at includes/Services/Analytics.php

449 lines 13.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Analytics service.
4 *
5 * Thin wrapper around the PostHog capture API. Sends anonymous product
6 * analytics so the WCPOS team can understand how the plugin is used and
7 * make better product decisions.
8 *
9 * Events are only sent when the user has explicitly opted in via the
10 * `tracking_consent` setting. All calls are no-ops otherwise, so callers
11 * can invoke them unconditionally.
12 *
13 * @package WCPOS\WooCommercePOS\Services
14 */
15
16 namespace WCPOS\WooCommercePOS\Services;
17
18 use WCPOS\WooCommercePOS\Services\Settings;
19 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
20 use WP_User;
21 use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;
22
23 /**
24 * Analytics service class.
25 */
26 class Analytics {
27 /**
28 * Default PostHog project token.
29 *
30 * Client-side PostHog project tokens are designed to be public. They
31 * authorize event ingestion into a specific project only.
32 *
33 * Override with the `WCPOS_POSTHOG_TOKEN` constant if needed.
34 *
35 * @var string
36 */
37 const DEFAULT_TOKEN = 'phc_BhTJzZ7fXMqcD4MiaUJQsQqPkEpu94yoSAthXFBWemvd';
38
39 /**
40 * Default PostHog ingestion host.
41 *
42 * Uses a reverse proxy on wcpos.com to reduce the chance of being
43 * blocked by privacy tooling. Override with `WCPOS_POSTHOG_HOST`.
44 *
45 * @var string
46 */
47 const DEFAULT_HOST = 'https://ph.wcpos.com';
48
49 /**
50 * Capture endpoint path.
51 *
52 * @var string
53 */
54 const CAPTURE_PATH = '/capture/';
55
56 /**
57 * De-dup window for impressions on AMBIENT upsell placements.
58 *
59 * An ambient placement renders as a side effect of unrelated work — the
60 * product editor, the plugins list — so a merchant re-arms a daily window
61 * simply by doing their job. Live data made the cost obvious: with a daily
62 * window `product_edit_price` alone logged 40,362 impressions from 414
63 * users (~97 each), and `upgrade_cta_viewed` grew to 90% of every event the
64 * project holds. That does not measure interest, it measures how often
65 * someone edits products, and it makes view -> click conversion meaningless
66 * (0.015% on that placement).
67 *
68 * A month still answers "was this CTA on screen for this merchant", which
69 * is the only question the upgrade funnel asks of an impression.
70 *
71 * Navigational placements — a settings tab, the landing page — keep the
72 * shorter default: the merchant chose to go there, so the visit is signal.
73 *
74 * @var int
75 */
76 const AMBIENT_IMPRESSION_TTL = MONTH_IN_SECONDS;
77
78 /**
79 * HTTP request timeout in seconds.
80 *
81 * Kept low because capture is fire-and-forget. We set
82 * `blocking => false` in practice, but the timeout still applies to
83 * the TCP connect step.
84 *
85 * @var float
86 */
87 const REQUEST_TIMEOUT = 2.0;
88
89 /**
90 * Singleton instance.
91 *
92 * @var null|self
93 */
94 private static $instance = null;
95
96 /**
97 * Cached consent state for the current request.
98 *
99 * @var null|bool
100 */
101 private $enabled_cache = null;
102
103 /**
104 * Get the singleton instance.
105 */
106 public static function instance(): self {
107 if ( null === self::$instance ) {
108 self::$instance = new self();
109 }
110
111 return self::$instance;
112 }
113
114 /**
115 * Reset the singleton. Intended for tests only.
116 */
117 public static function reset_instance(): void {
118 self::$instance = null;
119 }
120
121 /**
122 * Whether analytics is enabled for the current site.
123 *
124 * Returns true only when the user has explicitly allowed tracking
125 * via the general settings. Cached for the duration of the request.
126 */
127 public function is_enabled(): bool {
128 if ( null !== $this->enabled_cache ) {
129 return $this->enabled_cache;
130 }
131
132 $consent = Settings::instance()->tracking_consent();
133 $this->enabled_cache = ( 'allowed' === $consent );
134
135 return $this->enabled_cache;
136 }
137
138 /**
139 * Clear the cached consent state.
140 *
141 * Useful after programmatically changing the consent value within a
142 * single request (for example, the AJAX consent notice handler).
143 */
144 public function clear_consent_cache(): void {
145 $this->enabled_cache = null;
146 }
147
148 /**
149 * Capture an event.
150 *
151 * No-op unless analytics is enabled. Automatically attaches the
152 * current user's UUID as `distinct_id`, groups the event under the
153 * site UUID, and merges in a small set of default context properties.
154 *
155 * @param string $event Event name, e.g. `pro_link_clicked`.
156 * @param array $properties Event properties. Caller-supplied values
157 * take precedence over defaults.
158 * @param string $distinct_id_override Identity to attribute the event to.
159 * Defaults to the current user's UUID.
160 * Used by group identification and by
161 * scheduled events, which run without a
162 * logged-in user.
163 * @param string $timestamp ISO-8601 event time. Defaults to now.
164 * Set it when reporting something that
165 * happened earlier — an install event
166 * held back until consent was granted
167 * must keep its real install date or the
168 * retention cohorts are wrong.
169 *
170 * @return bool True when a request was dispatched, false otherwise.
171 */
172 public function capture( string $event, array $properties = array(), string $distinct_id_override = '', string $timestamp = '' ): bool {
173 if ( ! $this->is_enabled() ) {
174 return false;
175 }
176
177 if ( '' === $event ) {
178 return false;
179 }
180
181 $distinct_id = '' !== $distinct_id_override ? $distinct_id_override : $this->get_distinct_id();
182 if ( '' === $distinct_id ) {
183 return false;
184 }
185
186 $merged_properties = array_merge( $this->get_default_properties(), $properties );
187
188 // PostHog reserves $identify / $groupidentify for person / group
189 // definitions. Auto-attaching a $groups binding to those would
190 // either duplicate the event's own $group_type/$group_key or
191 // incorrectly cross-link them to an unrelated group, so only
192 // attach $groups to regular events.
193 if ( ! $this->is_reserved_event( $event ) ) {
194 $site_id = $this->get_site_id();
195 if ( '' !== $site_id ) {
196 $merged_properties['$groups'] = array( 'site' => $site_id );
197 }
198 }
199
200 $payload = array(
201 'api_key' => $this->get_token(),
202 'event' => $event,
203 'distinct_id' => $distinct_id,
204 'properties' => $merged_properties,
205 'timestamp' => '' !== $timestamp ? $timestamp : gmdate( 'c' ),
206 );
207
208 return $this->send( self::CAPTURE_PATH, $payload );
209 }
210
211 /**
212 * Capture an impression-style event at most once per de-dup window.
213 *
214 * Impression events such as upgrade CTA views can otherwise fire on
215 * every page render — a persistent admin link or a product-edit upsell
216 * field would emit hundreds of identical events per user, drowning the
217 * funnel and inflating ingestion. This de-duplicates per current user +
218 * key using a short-lived transient, so each impression slot is counted
219 * at most once per window.
220 *
221 * @param string $event Event name, e.g. `upgrade_cta_viewed`.
222 * @param array $properties Event properties.
223 * @param string $dedup_key Stable key for the impression slot (for
224 * example, the placement). Combined with the
225 * event name and current user UUID to form the
226 * transient key.
227 * @param int $ttl De-dup window in seconds. Defaults to a day.
228 *
229 * @return bool True when an event was dispatched, false when suppressed
230 * or analytics is disabled.
231 */
232 public function capture_once( string $event, array $properties = array(), string $dedup_key = '', int $ttl = DAY_IN_SECONDS ): bool {
233 if ( ! $this->is_enabled() ) {
234 return false;
235 }
236
237 $distinct_id = $this->get_distinct_id();
238 if ( '' === $distinct_id ) {
239 return false;
240 }
241
242 $transient_key = 'wcpos_imp_' . md5( $distinct_id . '|' . $event . '|' . $dedup_key );
243 if ( false !== get_transient( $transient_key ) ) {
244 return false;
245 }
246
247 $dispatched = $this->capture( $event, $properties );
248
249 // Only record the de-dup marker once the event actually dispatched, so
250 // a transient network failure does not permanently suppress the slot.
251 if ( $dispatched ) {
252 set_transient( $transient_key, 1, $ttl );
253 }
254
255 return $dispatched;
256 }
257
258 /**
259 * Set person properties on the current user.
260 *
261 * Uses the PostHog `$identify` event. Properties set via `$set_once`
262 * only apply the first time they are seen.
263 *
264 * @param array $set Properties to set (overwrite).
265 * @param array $set_once Properties to set only on first sighting.
266 */
267 public function identify( array $set = array(), array $set_once = array() ): bool {
268 if ( ! $this->is_enabled() ) {
269 return false;
270 }
271
272 $properties = array();
273 if ( ! empty( $set ) ) {
274 $properties['$set'] = $set;
275 }
276 if ( ! empty( $set_once ) ) {
277 $properties['$set_once'] = $set_once;
278 }
279
280 return $this->capture( '$identify', $properties );
281 }
282
283 /**
284 * Set group properties.
285 *
286 * Uses the PostHog `$groupidentify` event. Every plugin install maps
287 * to a single `site` group keyed by the site UUID.
288 *
289 * @param string $group_type Group type, e.g. `site`.
290 * @param string $group_key Group key, e.g. the site UUID.
291 * @param array $properties Group properties.
292 */
293 public function group( string $group_type, string $group_key, array $properties = array() ): bool {
294 if ( ! $this->is_enabled() ) {
295 return false;
296 }
297
298 if ( '' === $group_type || '' === $group_key ) {
299 return false;
300 }
301
302 // A group identification describes the site, not a person. When no user
303 // is logged in — the scheduled property refresh runs from cron — fall
304 // back to PostHog's own convention of keying the event by the group
305 // itself, so the refresh is not silently dropped for want of an identity.
306 $distinct_id = $this->get_distinct_id();
307 if ( '' === $distinct_id ) {
308 $distinct_id = $group_type . '_' . $group_key;
309 }
310
311 return $this->capture(
312 '$groupidentify',
313 array(
314 '$group_type' => $group_type,
315 '$group_key' => $group_key,
316 '$group_set' => $properties,
317 ),
318 $distinct_id
319 );
320 }
321
322 /**
323 * Get the PostHog project token.
324 *
325 * Allows override via constant (`WCPOS_POSTHOG_TOKEN`) or filter
326 * (`woocommerce_pos_posthog_token`) for self-hosted deployments.
327 */
328 public function get_token(): string {
329 $token = \defined( 'WCPOS_POSTHOG_TOKEN' ) ? (string) \WCPOS_POSTHOG_TOKEN : self::DEFAULT_TOKEN;
330
331 /**
332 * Filters the PostHog project token used for analytics.
333 *
334 * @since 1.8.14
335 *
336 * @param string $token The default project token.
337 */
338 return (string) apply_filters( 'woocommerce_pos_posthog_token', $token );
339 }
340
341 /**
342 * Get the PostHog host URL.
343 *
344 * Allows override via constant (`WCPOS_POSTHOG_HOST`) or filter
345 * (`woocommerce_pos_posthog_host`).
346 */
347 public function get_host(): string {
348 $host = \defined( 'WCPOS_POSTHOG_HOST' ) ? (string) \WCPOS_POSTHOG_HOST : self::DEFAULT_HOST;
349
350 /**
351 * Filters the PostHog host URL used for analytics.
352 *
353 * @since 1.8.14
354 *
355 * @param string $host The default host URL.
356 */
357 return untrailingslashit( (string) apply_filters( 'woocommerce_pos_posthog_host', $host ) );
358 }
359
360 /**
361 * Get the distinct ID for the current user.
362 *
363 * Delegates to Pos_Uuid — the sole authority for `_woocommerce_pos_uuid` — so
364 * analytics events carry the SAME identity the /cashier and /customers
365 * endpoints serve, lazily provisioning it for admin-only installs (where the
366 * POS frontend has never loaded).
367 *
368 * Empty string when no user is logged in.
369 */
370 public function get_distinct_id(): string {
371 $user = wp_get_current_user();
372 if ( ! $user instanceof WP_User || 0 === $user->ID ) {
373 return '';
374 }
375
376 return Pos_Uuid::ensure_user_uuid( $user );
377 }
378
379 /**
380 * Get the site UUID used as the `site` group key.
381 *
382 * Lazily provisions the site UUID if missing so admin-only
383 * installs (fresh plugin activation, no POS frontend load yet)
384 * still have a stable site identifier for grouping.
385 */
386 public function get_site_id(): string {
387 // The deactivation hook runs even when Activator::init() bailed on the
388 // WooCommerce check — in that request `new Init()` never ran, so
389 // wcpos-functions.php is not loaded and the helper does not exist.
390 // Read the option directly rather than fataling; an install that has
391 // ever run properly already has one, and a site that has not is not
392 // worth provisioning an identity for on its way out.
393 if ( ! \function_exists( 'wcpos_get_site_uuid' ) ) {
394 $uuid = get_option( 'woocommerce_pos_uuid', '' );
395
396 return \is_string( $uuid ) ? $uuid : '';
397 }
398
399 return wcpos_get_site_uuid();
400 }
401
402 /**
403 * Whether the given event name is a PostHog-reserved identifier
404 * event that should not have a `$groups` binding auto-attached.
405 *
406 * @param string $event Event name.
407 */
408 private function is_reserved_event( string $event ): bool {
409 return '$identify' === $event || '$groupidentify' === $event;
410 }
411
412 /**
413 * Get default properties attached to every captured event.
414 */
415 private function get_default_properties(): array {
416 return array(
417 'plugin_version' => PLUGIN_VERSION,
418 'pro_active' => class_exists( '\WCPOS\WooCommercePOSPro\WooCommercePOSPro' ),
419 'locale' => get_locale(),
420 );
421 }
422
423 /**
424 * Dispatch a non-blocking HTTPS POST to the PostHog ingestion host.
425 *
426 * @param string $path Endpoint path (e.g. /capture/).
427 * @param array $payload JSON payload.
428 */
429 private function send( string $path, array $payload ): bool {
430 $url = $this->get_host() . $path;
431 $body = wp_json_encode( $payload );
432 if ( false === $body ) {
433 return false;
434 }
435
436 $response = wp_remote_post(
437 $url,
438 array(
439 'blocking' => false,
440 'timeout' => self::REQUEST_TIMEOUT,
441 'headers' => array( 'Content-Type' => 'application/json' ),
442 'body' => $body,
443 )
444 );
445
446 return ! is_wp_error( $response );
447 }
448 }
449