PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.9.15
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.9.15
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.9.15, at includes/Services/Analytics.php

410 lines 11.1 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 Ramsey\Uuid\Uuid;
19 use WP_User;
20 use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;
21
22 /**
23 * Analytics service class.
24 */
25 class Analytics {
26 /**
27 * Default PostHog project token.
28 *
29 * Client-side PostHog project tokens are designed to be public. They
30 * authorize event ingestion into a specific project only.
31 *
32 * Override with the `WCPOS_POSTHOG_TOKEN` constant if needed.
33 *
34 * @var string
35 */
36 const DEFAULT_TOKEN = 'phc_BhTJzZ7fXMqcD4MiaUJQsQqPkEpu94yoSAthXFBWemvd';
37
38 /**
39 * Default PostHog ingestion host.
40 *
41 * Uses a reverse proxy on wcpos.com to reduce the chance of being
42 * blocked by privacy tooling. Override with `WCPOS_POSTHOG_HOST`.
43 *
44 * @var string
45 */
46 const DEFAULT_HOST = 'https://ph.wcpos.com';
47
48 /**
49 * Capture endpoint path.
50 *
51 * @var string
52 */
53 const CAPTURE_PATH = '/capture/';
54
55 /**
56 * HTTP request timeout in seconds.
57 *
58 * Kept low because capture is fire-and-forget. We set
59 * `blocking => false` in practice, but the timeout still applies to
60 * the TCP connect step.
61 *
62 * @var float
63 */
64 const REQUEST_TIMEOUT = 2.0;
65
66 /**
67 * Singleton instance.
68 *
69 * @var null|self
70 */
71 private static $instance = null;
72
73 /**
74 * Cached consent state for the current request.
75 *
76 * @var null|bool
77 */
78 private $enabled_cache = null;
79
80 /**
81 * Get the singleton instance.
82 */
83 public static function instance(): self {
84 if ( null === self::$instance ) {
85 self::$instance = new self();
86 }
87
88 return self::$instance;
89 }
90
91 /**
92 * Reset the singleton. Intended for tests only.
93 */
94 public static function reset_instance(): void {
95 self::$instance = null;
96 }
97
98 /**
99 * Whether analytics is enabled for the current site.
100 *
101 * Returns true only when the user has explicitly allowed tracking
102 * via the general settings. Cached for the duration of the request.
103 */
104 public function is_enabled(): bool {
105 if ( null !== $this->enabled_cache ) {
106 return $this->enabled_cache;
107 }
108
109 $consent = woocommerce_pos_get_settings( 'general', 'tracking_consent' );
110 $this->enabled_cache = ( 'allowed' === $consent );
111
112 return $this->enabled_cache;
113 }
114
115 /**
116 * Clear the cached consent state.
117 *
118 * Useful after programmatically changing the consent value within a
119 * single request (for example, the AJAX consent notice handler).
120 */
121 public function clear_consent_cache(): void {
122 $this->enabled_cache = null;
123 }
124
125 /**
126 * Capture an event.
127 *
128 * No-op unless analytics is enabled. Automatically attaches the
129 * current user's UUID as `distinct_id`, groups the event under the
130 * site UUID, and merges in a small set of default context properties.
131 *
132 * @param string $event Event name, e.g. `pro_link_clicked`.
133 * @param array $properties Event properties. Caller-supplied values
134 * take precedence over defaults.
135 *
136 * @return bool True when a request was dispatched, false otherwise.
137 */
138 public function capture( string $event, array $properties = array() ): bool {
139 if ( ! $this->is_enabled() ) {
140 return false;
141 }
142
143 if ( '' === $event ) {
144 return false;
145 }
146
147 $distinct_id = $this->get_distinct_id();
148 if ( '' === $distinct_id ) {
149 return false;
150 }
151
152 $merged_properties = array_merge( $this->get_default_properties(), $properties );
153
154 // PostHog reserves $identify / $groupidentify for person / group
155 // definitions. Auto-attaching a $groups binding to those would
156 // either duplicate the event's own $group_type/$group_key or
157 // incorrectly cross-link them to an unrelated group, so only
158 // attach $groups to regular events.
159 if ( ! $this->is_reserved_event( $event ) ) {
160 $site_id = $this->get_site_id();
161 if ( '' !== $site_id ) {
162 $merged_properties['$groups'] = array( 'site' => $site_id );
163 }
164 }
165
166 $payload = array(
167 'api_key' => $this->get_token(),
168 'event' => $event,
169 'distinct_id' => $distinct_id,
170 'properties' => $merged_properties,
171 'timestamp' => gmdate( 'c' ),
172 );
173
174 return $this->send( self::CAPTURE_PATH, $payload );
175 }
176
177 /**
178 * Capture an impression-style event at most once per de-dup window.
179 *
180 * Impression events such as upgrade CTA views can otherwise fire on
181 * every page render — a persistent admin link or a product-edit upsell
182 * field would emit hundreds of identical events per user, drowning the
183 * funnel and inflating ingestion. This de-duplicates per current user +
184 * key using a short-lived transient, so each impression slot is counted
185 * at most once per window.
186 *
187 * @param string $event Event name, e.g. `upgrade_cta_viewed`.
188 * @param array $properties Event properties.
189 * @param string $dedup_key Stable key for the impression slot (for
190 * example, the placement). Combined with the
191 * event name and current user UUID to form the
192 * transient key.
193 * @param int $ttl De-dup window in seconds. Defaults to a day.
194 *
195 * @return bool True when an event was dispatched, false when suppressed
196 * or analytics is disabled.
197 */
198 public function capture_once( string $event, array $properties = array(), string $dedup_key = '', int $ttl = DAY_IN_SECONDS ): bool {
199 if ( ! $this->is_enabled() ) {
200 return false;
201 }
202
203 $distinct_id = $this->get_distinct_id();
204 if ( '' === $distinct_id ) {
205 return false;
206 }
207
208 $transient_key = 'wcpos_imp_' . md5( $distinct_id . '|' . $event . '|' . $dedup_key );
209 if ( false !== get_transient( $transient_key ) ) {
210 return false;
211 }
212
213 $dispatched = $this->capture( $event, $properties );
214
215 // Only record the de-dup marker once the event actually dispatched, so
216 // a transient network failure does not permanently suppress the slot.
217 if ( $dispatched ) {
218 set_transient( $transient_key, 1, $ttl );
219 }
220
221 return $dispatched;
222 }
223
224 /**
225 * Set person properties on the current user.
226 *
227 * Uses the PostHog `$identify` event. Properties set via `$set_once`
228 * only apply the first time they are seen.
229 *
230 * @param array $set Properties to set (overwrite).
231 * @param array $set_once Properties to set only on first sighting.
232 */
233 public function identify( array $set = array(), array $set_once = array() ): bool {
234 if ( ! $this->is_enabled() ) {
235 return false;
236 }
237
238 $properties = array();
239 if ( ! empty( $set ) ) {
240 $properties['$set'] = $set;
241 }
242 if ( ! empty( $set_once ) ) {
243 $properties['$set_once'] = $set_once;
244 }
245
246 return $this->capture( '$identify', $properties );
247 }
248
249 /**
250 * Set group properties.
251 *
252 * Uses the PostHog `$groupidentify` event. Every plugin install maps
253 * to a single `site` group keyed by the site UUID.
254 *
255 * @param string $group_type Group type, e.g. `site`.
256 * @param string $group_key Group key, e.g. the site UUID.
257 * @param array $properties Group properties.
258 */
259 public function group( string $group_type, string $group_key, array $properties = array() ): bool {
260 if ( ! $this->is_enabled() ) {
261 return false;
262 }
263
264 if ( '' === $group_type || '' === $group_key ) {
265 return false;
266 }
267
268 return $this->capture(
269 '$groupidentify',
270 array(
271 '$group_type' => $group_type,
272 '$group_key' => $group_key,
273 '$group_set' => $properties,
274 )
275 );
276 }
277
278 /**
279 * Get the PostHog project token.
280 *
281 * Allows override via constant (`WCPOS_POSTHOG_TOKEN`) or filter
282 * (`woocommerce_pos_posthog_token`) for self-hosted deployments.
283 */
284 public function get_token(): string {
285 $token = \defined( 'WCPOS_POSTHOG_TOKEN' ) ? (string) \WCPOS_POSTHOG_TOKEN : self::DEFAULT_TOKEN;
286
287 /**
288 * Filters the PostHog project token used for analytics.
289 *
290 * @since 1.8.14
291 *
292 * @param string $token The default project token.
293 */
294 return (string) apply_filters( 'woocommerce_pos_posthog_token', $token );
295 }
296
297 /**
298 * Get the PostHog host URL.
299 *
300 * Allows override via constant (`WCPOS_POSTHOG_HOST`) or filter
301 * (`woocommerce_pos_posthog_host`).
302 */
303 public function get_host(): string {
304 $host = \defined( 'WCPOS_POSTHOG_HOST' ) ? (string) \WCPOS_POSTHOG_HOST : self::DEFAULT_HOST;
305
306 /**
307 * Filters the PostHog host URL used for analytics.
308 *
309 * @since 1.8.14
310 *
311 * @param string $host The default host URL.
312 */
313 return untrailingslashit( (string) apply_filters( 'woocommerce_pos_posthog_host', $host ) );
314 }
315
316 /**
317 * Get the distinct ID for the current user.
318 *
319 * Returns the user's POS UUID meta, lazily provisioning it if
320 * missing. This matches the existing pattern in
321 * `Templates\Frontend` for users who load the POS frontend, and
322 * ensures analytics events from the WP admin (where `Frontend` is
323 * never loaded) still have a stable `distinct_id`.
324 *
325 * Empty string when no user is logged in.
326 */
327 public function get_distinct_id(): string {
328 $user = wp_get_current_user();
329 if ( ! $user instanceof WP_User || 0 === $user->ID ) {
330 return '';
331 }
332
333 $uuid = get_user_meta( $user->ID, '_woocommerce_pos_uuid', true );
334 if ( \is_string( $uuid ) && '' !== $uuid ) {
335 return $uuid;
336 }
337
338 $uuid = Uuid::uuid4()->toString();
339 update_user_meta( $user->ID, '_woocommerce_pos_uuid', $uuid );
340
341 return $uuid;
342 }
343
344 /**
345 * Get the site UUID used as the `site` group key.
346 *
347 * Lazily provisions the site UUID if missing so admin-only
348 * installs (fresh plugin activation, no POS frontend load yet)
349 * still have a stable site identifier for grouping.
350 */
351 public function get_site_id(): string {
352 $uuid = get_option( 'woocommerce_pos_uuid', '' );
353 if ( \is_string( $uuid ) && '' !== $uuid ) {
354 return $uuid;
355 }
356
357 $uuid = Uuid::uuid4()->toString();
358 update_option( 'woocommerce_pos_uuid', $uuid );
359
360 return $uuid;
361 }
362
363 /**
364 * Whether the given event name is a PostHog-reserved identifier
365 * event that should not have a `$groups` binding auto-attached.
366 *
367 * @param string $event Event name.
368 */
369 private function is_reserved_event( string $event ): bool {
370 return '$identify' === $event || '$groupidentify' === $event;
371 }
372
373 /**
374 * Get default properties attached to every captured event.
375 */
376 private function get_default_properties(): array {
377 return array(
378 'plugin_version' => PLUGIN_VERSION,
379 'pro_active' => class_exists( '\WCPOS\WooCommercePOSPro\WooCommercePOSPro' ),
380 'locale' => get_locale(),
381 );
382 }
383
384 /**
385 * Dispatch a non-blocking HTTPS POST to the PostHog ingestion host.
386 *
387 * @param string $path Endpoint path (e.g. /capture/).
388 * @param array $payload JSON payload.
389 */
390 private function send( string $path, array $payload ): bool {
391 $url = $this->get_host() . $path;
392 $body = wp_json_encode( $payload );
393 if ( false === $body ) {
394 return false;
395 }
396
397 $response = wp_remote_post(
398 $url,
399 array(
400 'blocking' => false,
401 'timeout' => self::REQUEST_TIMEOUT,
402 'headers' => array( 'Content-Type' => 'application/json' ),
403 'body' => $body,
404 )
405 );
406
407 return ! is_wp_error( $response );
408 }
409 }
410