PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 1.9.11 1.9.10 All 159 releases
woocommerce-pos / includes / Services / Analytics.php

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

469 lines 14.4 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 // The PERSISTED consent, not the filtered read view — otherwise any
133 // plugin filtering woocommerce_pos_general_settings could switch
134 // telemetry on for a merchant who declined it. Same gate as
135 // Services\Error_Reporter.
136 $consent = Settings::instance()->raw_tracking_consent();
137 $this->enabled_cache = ( 'allowed' === $consent );
138
139 return $this->enabled_cache;
140 }
141
142 /**
143 * Clear the cached consent state.
144 *
145 * Useful after programmatically changing the consent value within a
146 * single request (for example, the AJAX consent notice handler).
147 */
148 public function clear_consent_cache(): void {
149 $this->enabled_cache = null;
150 }
151
152 /**
153 * Capture an event.
154 *
155 * No-op unless analytics is enabled. Automatically attaches the
156 * current user's UUID as `distinct_id`, groups the event under the
157 * site UUID, and merges in a small set of default context properties.
158 *
159 * @param string $event Event name, e.g. `pro_link_clicked`.
160 * @param array $properties Event properties. Caller-supplied values
161 * take precedence over defaults.
162 * @param string $distinct_id_override Identity to attribute the event to.
163 * Defaults to the current user's UUID.
164 * Used by group identification and by
165 * scheduled events, which run without a
166 * logged-in user.
167 * @param string $timestamp ISO-8601 event time. Defaults to now.
168 * Set it when reporting something that
169 * happened earlier — an install event
170 * held back until consent was granted
171 * must keep its real install date or the
172 * retention cohorts are wrong.
173 *
174 * @return bool True when a request was dispatched, false otherwise.
175 */
176 public function capture( string $event, array $properties = array(), string $distinct_id_override = '', string $timestamp = '' ): bool {
177 if ( ! $this->is_enabled() ) {
178 return false;
179 }
180
181 if ( '' === $event ) {
182 return false;
183 }
184
185 $distinct_id = '' !== $distinct_id_override ? $distinct_id_override : $this->get_distinct_id();
186 if ( '' === $distinct_id ) {
187 return false;
188 }
189
190 $merged_properties = array_merge( $this->get_default_properties(), $properties );
191
192 // PostHog reserves $identify / $groupidentify for person / group
193 // definitions. Auto-attaching a $groups binding to those would
194 // either duplicate the event's own $group_type/$group_key or
195 // incorrectly cross-link them to an unrelated group, so only
196 // attach $groups to regular events.
197 if ( ! $this->is_reserved_event( $event ) ) {
198 $site_id = $this->get_site_id();
199 if ( '' !== $site_id ) {
200 $merged_properties['$groups'] = array( 'site' => $site_id );
201 }
202 }
203
204 $payload = array(
205 'api_key' => $this->get_token(),
206 'event' => $event,
207 'distinct_id' => $distinct_id,
208 'properties' => $merged_properties,
209 'timestamp' => '' !== $timestamp ? $timestamp : gmdate( 'c' ),
210 );
211
212 return $this->send( self::CAPTURE_PATH, $payload );
213 }
214
215 /**
216 * Capture an impression-style event at most once per de-dup window.
217 *
218 * Impression events such as upgrade CTA views can otherwise fire on
219 * every page render — a persistent admin link or a product-edit upsell
220 * field would emit hundreds of identical events per user, drowning the
221 * funnel and inflating ingestion. This de-duplicates per current user +
222 * key using a short-lived transient, so each impression slot is counted
223 * at most once per window.
224 *
225 * @param string $event Event name, e.g. `upgrade_cta_viewed`.
226 * @param array $properties Event properties.
227 * @param string $dedup_key Stable key for the impression slot (for
228 * example, the placement). Combined with the
229 * event name and current user UUID to form the
230 * transient key.
231 * @param int $ttl De-dup window in seconds. Defaults to a day.
232 *
233 * @return bool True when an event was dispatched, false when suppressed
234 * or analytics is disabled.
235 */
236 public function capture_once( string $event, array $properties = array(), string $dedup_key = '', int $ttl = DAY_IN_SECONDS ): bool {
237 if ( ! $this->is_enabled() ) {
238 return false;
239 }
240
241 $distinct_id = $this->get_distinct_id();
242 if ( '' === $distinct_id ) {
243 return false;
244 }
245
246 $transient_key = 'wcpos_imp_' . md5( $distinct_id . '|' . $event . '|' . $dedup_key );
247 if ( false !== get_transient( $transient_key ) ) {
248 return false;
249 }
250
251 $dispatched = $this->capture( $event, $properties );
252
253 // Only record the de-dup marker once the event actually dispatched, so
254 // a transient network failure does not permanently suppress the slot.
255 if ( $dispatched ) {
256 set_transient( $transient_key, 1, $ttl );
257 }
258
259 return $dispatched;
260 }
261
262 /**
263 * Set person properties on the current user.
264 *
265 * Uses the PostHog `$identify` event. Properties set via `$set_once`
266 * only apply the first time they are seen.
267 *
268 * @param array $set Properties to set (overwrite).
269 * @param array $set_once Properties to set only on first sighting.
270 */
271 public function identify( array $set = array(), array $set_once = array() ): bool {
272 if ( ! $this->is_enabled() ) {
273 return false;
274 }
275
276 $properties = array();
277 if ( ! empty( $set ) ) {
278 $properties['$set'] = $set;
279 }
280 if ( ! empty( $set_once ) ) {
281 $properties['$set_once'] = $set_once;
282 }
283
284 return $this->capture( '$identify', $properties );
285 }
286
287 /**
288 * Set group properties.
289 *
290 * Uses the PostHog `$groupidentify` event. Every plugin install maps
291 * to a single `site` group keyed by the site UUID.
292 *
293 * @param string $group_type Group type, e.g. `site`.
294 * @param string $group_key Group key, e.g. the site UUID.
295 * @param array $properties Group properties.
296 */
297 public function group( string $group_type, string $group_key, array $properties = array() ): bool {
298 if ( ! $this->is_enabled() ) {
299 return false;
300 }
301
302 if ( '' === $group_type || '' === $group_key ) {
303 return false;
304 }
305
306 // A group identification describes the site, not a person. When no user
307 // is logged in — the scheduled property refresh runs from cron — fall
308 // back to PostHog's own convention of keying the event by the group
309 // itself, so the refresh is not silently dropped for want of an identity.
310 $distinct_id = $this->get_distinct_id();
311 if ( '' === $distinct_id ) {
312 $distinct_id = $group_type . '_' . $group_key;
313 }
314
315 return $this->capture(
316 '$groupidentify',
317 array(
318 '$group_type' => $group_type,
319 '$group_key' => $group_key,
320 '$group_set' => $properties,
321 ),
322 $distinct_id
323 );
324 }
325
326 /**
327 * Get the PostHog project token.
328 *
329 * Allows override via constant (`WCPOS_POSTHOG_TOKEN`) or filter
330 * (`woocommerce_pos_posthog_token`) for self-hosted deployments.
331 */
332 public function get_token(): string {
333 $token = \defined( 'WCPOS_POSTHOG_TOKEN' ) ? (string) \WCPOS_POSTHOG_TOKEN : self::DEFAULT_TOKEN;
334
335 /**
336 * Filters the PostHog project token used for analytics.
337 *
338 * @since 1.8.14
339 *
340 * @param string $token The default project token.
341 */
342 return (string) apply_filters( 'woocommerce_pos_posthog_token', $token );
343 }
344
345 /**
346 * Get the PostHog host URL.
347 *
348 * Allows override via constant (`WCPOS_POSTHOG_HOST`) or filter
349 * (`woocommerce_pos_posthog_host`).
350 */
351 public function get_host(): string {
352 $host = \defined( 'WCPOS_POSTHOG_HOST' ) ? (string) \WCPOS_POSTHOG_HOST : self::DEFAULT_HOST;
353
354 /**
355 * Filters the PostHog host URL used for analytics.
356 *
357 * @since 1.8.14
358 *
359 * @param string $host The default host URL.
360 */
361 return untrailingslashit( (string) apply_filters( 'woocommerce_pos_posthog_host', $host ) );
362 }
363
364 /**
365 * Get the distinct ID for the current user.
366 *
367 * Delegates to Pos_Uuid — the sole authority for `_woocommerce_pos_uuid` — so
368 * analytics events carry the SAME identity the /cashier and /customers
369 * endpoints serve, lazily provisioning it for admin-only installs (where the
370 * POS frontend has never loaded).
371 *
372 * Empty string when no user is logged in.
373 */
374 public function get_distinct_id(): string {
375 $user = wp_get_current_user();
376 if ( ! $user instanceof WP_User || 0 === $user->ID ) {
377 return '';
378 }
379
380 return Pos_Uuid::ensure_user_uuid( $user );
381 }
382
383 /**
384 * Get the site UUID used as the `site` group key.
385 *
386 * Lazily provisions the site UUID if missing so admin-only
387 * installs (fresh plugin activation, no POS frontend load yet)
388 * still have a stable site identifier for grouping.
389 */
390 public function get_site_id(): string {
391 // The deactivation hook runs even when Activator::init() bailed on the
392 // WooCommerce check — in that request `new Init()` never ran, so
393 // wcpos-functions.php is not loaded and the helper does not exist.
394 // Read the option directly rather than fataling; an install that has
395 // ever run properly already has one, and a site that has not is not
396 // worth provisioning an identity for on its way out.
397 if ( ! \function_exists( 'wcpos_get_site_uuid' ) ) {
398 $uuid = get_option( 'woocommerce_pos_uuid', '' );
399
400 return \is_string( $uuid ) ? $uuid : '';
401 }
402
403 return wcpos_get_site_uuid();
404 }
405
406 /**
407 * Whether the given event name is a PostHog-reserved identifier
408 * event that should not have a `$groups` binding auto-attached.
409 *
410 * @param string $event Event name.
411 */
412 private function is_reserved_event( string $event ): bool {
413 return '$identify' === $event || '$groupidentify' === $event;
414 }
415
416 /**
417 * Get default properties attached to every captured event.
418 */
419 private function get_default_properties(): array {
420 return array(
421 'plugin_version' => PLUGIN_VERSION,
422 'pro_active' => $this->is_pro_active(),
423 'locale' => get_locale(),
424 );
425 }
426
427 /**
428 * Whether the Pro plugin is active, safe to call before Init has run.
429 *
430 * Same situation as get_site_id(): the deactivation hook can fire in a
431 * request where the WooCommerce check failed, Init never ran, and
432 * wcpos-functions.php is not loaded. Fall back to the constant the helper
433 * itself reads rather than fataling on the way out.
434 */
435 private function is_pro_active(): bool {
436 if ( ! \function_exists( 'wcpos_is_pro_active' ) ) {
437 return \defined( 'WCPOS\WooCommercePOSPro\VERSION' );
438 }
439
440 return wcpos_is_pro_active();
441 }
442
443 /**
444 * Dispatch a non-blocking HTTPS POST to the PostHog ingestion host.
445 *
446 * @param string $path Endpoint path (e.g. /capture/).
447 * @param array $payload JSON payload.
448 */
449 private function send( string $path, array $payload ): bool {
450 $url = $this->get_host() . $path;
451 $body = wp_json_encode( $payload );
452 if ( false === $body ) {
453 return false;
454 }
455
456 $response = wp_remote_post(
457 $url,
458 array(
459 'blocking' => false,
460 'timeout' => self::REQUEST_TIMEOUT,
461 'headers' => array( 'Content-Type' => 'application/json' ),
462 'body' => $body,
463 )
464 );
465
466 return ! is_wp_error( $response );
467 }
468 }
469