PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
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 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Lifecycle_Events.php

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

522 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Install lifecycle analytics.
4 *
5 * Reports the four events that make retention measurable — install, upgrade,
6 * deactivation, uninstall — and keeps the PostHog `site` group properties
7 * current via a daily refresh.
8 *
9 * The awkward part of this surface is that consent and installation happen in
10 * the wrong order. `tracking_consent` starts at `undecided` and the consent
11 * pop-up is only shown on the NEXT admin page load, so an install event fired
12 * from the activation hook is guaranteed to be suppressed by the consent gate
13 * and lost. Install and upgrade events are therefore RECORDED at the moment
14 * they happen and REPORTED once consent allows it, carrying their original
15 * timestamp so retention cohorts stay accurate.
16 *
17 * Consent is honoured at both ends: nothing is queued once the user has said
18 * no, and a queued event is discarded rather than sent if the answer turns out
19 * to be no.
20 *
21 * @package WCPOS\WooCommercePOS\Services
22 */
23
24 namespace WCPOS\WooCommercePOS\Services;
25
26 /**
27 * Lifecycle_Events service class.
28 */
29 class Lifecycle_Events {
30 /**
31 * Option holding events recorded before consent was decided.
32 *
33 * @var string
34 */
35 const PENDING_OPTION = 'woocommerce_pos_analytics_pending_events';
36
37 /**
38 * Option latching that the install event has been recorded.
39 *
40 * Set at activation and never cleared while the plugin is installed, so a
41 * deactivate/reactivate cycle does not report a second install.
42 *
43 * @var string
44 */
45 const INSTALL_RECORDED_OPTION = 'woocommerce_pos_analytics_install_recorded';
46
47 /**
48 * Cron hook for the daily group property refresh.
49 *
50 * @var string
51 */
52 const REFRESH_HOOK = 'wcpos_analytics_group_refresh';
53
54 /**
55 * Option holding the most recently reported order-count band.
56 *
57 * The uninstaller runs with no plugin code loaded and no warm cache to rely
58 * on. Persisting the band each refresh means the churn metric survives an
59 * uninstall that happens hours after the last page load.
60 *
61 * @var string
62 */
63 const LAST_ORDER_BAND_OPTION = 'woocommerce_pos_analytics_order_band';
64
65 /**
66 * Option latching that the site's first POS app open has been recorded.
67 *
68 * Written regardless of consent: it is a local flag and nothing leaves the
69 * site. Latching only for consenting sites would mean a store that used the
70 * POS for a month before saying yes has its next open reported as its first.
71 *
72 * @var string
73 */
74 const FIRST_OPEN_OPTION = 'woocommerce_pos_first_open_recorded';
75
76 /**
77 * The value the first-open latch stores.
78 *
79 * DO NOT make this a timestamp. `add_option()` is not the atomic claim it
80 * looks like: it does a get_option() check and then an
81 * `INSERT ... ON DUPLICATE KEY UPDATE`. Two racing callers can both pass the
82 * check, and if their values DIFFER the loser's upsert changes the row, so
83 * MySQL reports affected rows and add_option() returns true for both — two
84 * "firsts" for one event. With an identical value the loser's upsert changes
85 * nothing, affects 0 rows, and add_option() correctly returns false.
86 *
87 * The constant is what makes the latch safe. The timestamp people will want
88 * to store here is already on the event itself.
89 *
90 * @var string
91 */
92 const LATCH_VALUE = '1';
93
94 /**
95 * Maximum queued events.
96 *
97 * The queue only ever holds one install plus a handful of upgrades, so this
98 * is a backstop against an unbounded option, not a working limit.
99 *
100 * @var int
101 */
102 const MAX_PENDING = 20;
103
104 /**
105 * Register the hooks this service owns.
106 */
107 public function register_hooks(): void {
108 add_action( 'admin_init', array( $this, 'flush_pending' ) );
109 add_action( 'admin_init', array( $this, 'maybe_schedule_refresh' ) );
110 add_action( self::REFRESH_HOOK, array( $this, 'refresh_group_properties' ) );
111 }
112
113 /**
114 * Record the install event. Called from the activation hook.
115 *
116 * No-op after the first call, so only a genuine first install reports.
117 */
118 public function record_install(): void {
119 if ( get_option( self::INSTALL_RECORDED_OPTION ) ) {
120 return;
121 }
122
123 // Latch before recording: if the record fails we would rather lose one
124 // install event than report an install on every reactivation.
125 update_option( self::INSTALL_RECORDED_OPTION, 1, false );
126
127 // Sites that predate this code are not new installs — reporting one on
128 // the upgrade that introduces this latch would invent an install spike
129 // out of the existing user base. Two independent signals say "WCPOS has
130 // run here before": a stored db version (written by the first upgrade
131 // pass) and an install timestamp (written the first time the landing
132 // profile is built). Latch silently on either.
133 if ( '0' !== (string) Settings::get_db_version() ) {
134 return;
135 }
136
137 if ( false !== get_option( 'woocommerce_pos_installed_at' ) ) {
138 return;
139 }
140
141 // Stamp the install epoch NOW. Landing_Profile would otherwise create it
142 // the first time the profile is built — which, for a site that leaves
143 // consent undecided for a month, is a month late. Every days_since_install
144 // the site ever reports would be short by that gap.
145 add_option( 'woocommerce_pos_installed_at', time() );
146
147 $this->record( 'wcpos_installed' );
148 }
149
150 /**
151 * Record a version upgrade.
152 *
153 * @param string $from_version Version being upgraded from.
154 * @param string $to_version Version being upgraded to.
155 */
156 public function record_upgrade( string $from_version, string $to_version ): void {
157 // A fresh install runs the upgrade path with no previous version. That
158 // is an install, and it has already been reported as one.
159 if ( '' === $from_version || '0' === $from_version ) {
160 return;
161 }
162
163 $this->record(
164 'wcpos_upgraded',
165 array(
166 'from_version' => $from_version,
167 'to_version' => $to_version,
168 )
169 );
170 }
171
172 /**
173 * Report that the POS app was opened.
174 *
175 * The activation step no admin-side signal can see. Recorded from the POS
176 * template render rather than by tracking the menu link, so a bookmark, a
177 * direct URL or a till that never visits wp-admin all count — and so it
178 * counts opens rather than clicks that may never arrive.
179 *
180 * De-duplicated per user per day. A till is reloaded constantly; without a
181 * window this would repeat the mistake that made `upgrade_cta_viewed` 90% of
182 * the dataset. A day is also the useful unit: it makes this a daily-active
183 * signal rather than a page-load counter.
184 *
185 * The site's first open is flagged rather than given its own event name, so
186 * activation and engagement come off one series.
187 */
188 public function report_app_opened(): void {
189 // Latch the first open BEFORE the consent check, and never transmit it
190 // from here — it is a local option, nothing leaves the site. Latching
191 // only for consenting sites would mean a store that used the POS for a
192 // month and then said yes would have its next open reported as its
193 // first, which is untrue. This way an unknown first open stays unknown
194 // rather than becoming a wrong one.
195 //
196 // See LATCH_VALUE: the constant is what makes this a safe claim.
197 // Autoloaded because it is read on every POS open, and it is one byte.
198 $is_first_open = add_option( self::FIRST_OPEN_OPTION, self::LATCH_VALUE, '', true );
199
200 $analytics = Analytics::instance();
201
202 if ( ! $analytics->is_enabled() ) {
203 return;
204 }
205
206 $analytics->capture_once(
207 'pos_app_opened',
208 array( 'is_first_open' => $is_first_open ),
209 'pos_app_opened'
210 );
211
212 // A POS-only store may never load a wp-admin page, and admin_init is
213 // where the queue is normally drained. Without this, events recorded
214 // before consent — the install, the first sale — would sit unsent
215 // forever on exactly the stores that use the product most.
216 $this->flush_pending();
217 }
218
219 /**
220 * Record that the consent prompt was shown.
221 *
222 * This is the one event whose subject has not consented yet — by
223 * definition, since the prompt only renders while the answer is undecided.
224 * So it is queued, never sent, and reaches PostHog only if the user goes on
225 * to say yes. Somebody who declines or ignores the prompt transmits
226 * nothing, which is the only honest reading of what they were asked.
227 *
228 * The consequence, stated plainly because it limits what the data can
229 * answer: we see views only for people who accepted, so a true acceptance
230 * RATE is not computable from plugin telemetry and never will be. What this
231 * does answer is which surface converted and how long the decision took.
232 * For the rate, compare consenting sites against the public wordpress.org
233 * active-install count — no extra collection required.
234 *
235 * @param string $surface Where the prompt was shown: `modal` or `callout`.
236 */
237 public function record_consent_prompt_viewed( string $surface ): void {
238 // The prompt re-renders on every allowed admin screen until the user
239 // answers, so record the first sighting only.
240 foreach ( (array) get_option( self::PENDING_OPTION, array() ) as $entry ) {
241 if ( \is_array( $entry ) && 'consent_notice_viewed' === ( $entry['event'] ?? '' ) ) {
242 return;
243 }
244 }
245
246 $this->record( 'consent_notice_viewed', array( 'surface' => $surface ) );
247 }
248
249 /**
250 * Report that consent was granted.
251 *
252 * Sent immediately: the user has just said yes, so the gate is open. Also
253 * flushes anything queued while they were deciding, rather than leaving it
254 * for the next admin page load.
255 *
256 * There is deliberately no counterpart for "declined" or "dismissed".
257 * Reporting that someone refused telemetry, by sending telemetry, is the
258 * one thing this surface must never do.
259 *
260 * No surface is recorded: the server cannot tell which prompt the user
261 * answered in — both can be on screen — and the paired
262 * `consent_notice_viewed` already carries the surface that was shown.
263 */
264 public function report_consent_granted(): void {
265 $analytics = Analytics::instance();
266
267 // The choice was written in this request; the cached answer predates it.
268 $analytics->clear_consent_cache();
269
270 if ( ! $analytics->is_enabled() ) {
271 return;
272 }
273
274 $installed_at = (int) get_option( 'woocommerce_pos_installed_at', 0 );
275 $properties = array();
276
277 if ( $installed_at > 0 ) {
278 $properties['days_since_install'] = max( 0, (int) floor( ( time() - $installed_at ) / DAY_IN_SECONDS ) );
279 }
280
281 $analytics->capture( 'consent_notice_accepted', $properties );
282
283 // Send what was held back while the answer was pending.
284 $this->flush_pending();
285 }
286
287 /**
288 * Report the deactivation event. Called from the deactivation hook.
289 *
290 * Reported immediately rather than queued: a deactivated plugin never gets
291 * another admin page load to flush from, so an unsent deactivation would
292 * sit in the queue until the user reactivates, by which point it is a lie.
293 */
294 public function report_deactivation(): void {
295 // A network-wide deactivation walks every blog in one request, and
296 // Analytics caches the consent answer for the request. Without this the
297 // first blog's "yes" would be reused for blogs that said no.
298 Analytics::instance()->clear_consent_cache();
299
300 // Check consent before gathering anything: the churn properties run
301 // store queries, and a site that opted out should not pay for them.
302 if ( ! Analytics::instance()->is_enabled() ) {
303 return;
304 }
305
306 $analytics = Analytics::instance();
307
308 // `wp plugin deactivate` runs with no current user, so get_distinct_id()
309 // comes back empty and capture() would drop the event. Fall back to the
310 // site identity, the same way the group refresh and the uninstall
311 // reporter do.
312 $distinct_id = $analytics->get_distinct_id();
313 if ( '' === $distinct_id ) {
314 $site_id = $analytics->get_site_id();
315 if ( '' === $site_id ) {
316 return;
317 }
318
319 $distinct_id = 'site_' . $site_id;
320 }
321
322 $analytics->capture( 'wcpos_deactivated', $this->get_churn_properties(), $distinct_id );
323 }
324
325 /**
326 * Throw away anything queued while the answer was pending.
327 *
328 * Called when the user declines, so the refusal takes effect in the request
329 * that records it rather than whenever an admin page next happens to load.
330 */
331 public function discard_pending(): void {
332 delete_option( self::PENDING_OPTION );
333 }
334
335 /**
336 * Send any events recorded before consent was decided.
337 *
338 * Gated on consent first so that a site which has not opted in never pays
339 * for the queue lookup.
340 */
341 public function flush_pending(): void {
342 if ( ! Analytics::instance()->is_enabled() ) {
343 // A queued event outlives an undecided answer. Once the answer is
344 // no, drop it rather than leaving it to sit in the options table
345 // waiting for a consent that is not coming.
346 // The persisted answer: a settings filter must not be able to keep a
347 // declined merchant's queued events sitting in the options table.
348 if ( 'denied' === Settings::instance()->raw_tracking_consent() ) {
349 delete_option( self::PENDING_OPTION );
350 }
351
352 return;
353 }
354
355 $pending = get_option( self::PENDING_OPTION );
356 if ( empty( $pending ) || ! \is_array( $pending ) ) {
357 return;
358 }
359
360 // Clear first. A failed send is not worth retrying forever, and leaving
361 // the queue populated would re-send on every admin page load.
362 delete_option( self::PENDING_OPTION );
363
364 $analytics = Analytics::instance();
365
366 foreach ( $pending as $entry ) {
367 if ( ! \is_array( $entry ) || empty( $entry['event'] ) ) {
368 continue;
369 }
370
371 $analytics->capture(
372 (string) $entry['event'],
373 \is_array( $entry['properties'] ?? null ) ? $entry['properties'] : array(),
374 '',
375 isset( $entry['timestamp'] ) ? (string) $entry['timestamp'] : ''
376 );
377 }
378
379 // Queued events describe the install, so the site profile that goes with
380 // them is worth sending in the same pass.
381 $this->refresh_group_properties();
382 }
383
384 /**
385 * Schedule the daily group property refresh if consent allows it.
386 */
387 public function maybe_schedule_refresh(): void {
388 if ( ! Analytics::instance()->is_enabled() ) {
389 // Consent can be withdrawn — stop refreshing if it has been. Guarded
390 // so an opted-out site does not touch the cron array on every load.
391 if ( wp_next_scheduled( self::REFRESH_HOOK ) ) {
392 $this->clear_schedule();
393 }
394
395 return;
396 }
397
398 if ( ! wp_next_scheduled( self::REFRESH_HOOK ) ) {
399 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::REFRESH_HOOK );
400 }
401 }
402
403 /**
404 * Clear the scheduled refresh.
405 */
406 public function clear_schedule(): void {
407 wp_clear_scheduled_hook( self::REFRESH_HOOK );
408 }
409
410 /**
411 * Transient guarding the on-page-load group refresh.
412 *
413 * @var string
414 */
415 const REFRESH_THROTTLE_TRANSIENT = 'wcpos_analytics_group_refreshed';
416
417 /**
418 * Refresh the site profile from a page load, at most once a day.
419 *
420 * The scheduled refresh is the primary path; this is the fallback for
421 * installs where WP-Cron is unreliable or disabled. Throttled because the
422 * profile is a slow-moving description of the site, not a page-view metric.
423 */
424 public function maybe_refresh_group_properties(): void {
425 if ( ! Analytics::instance()->is_enabled() ) {
426 return;
427 }
428
429 if ( false !== get_transient( self::REFRESH_THROTTLE_TRANSIENT ) ) {
430 return;
431 }
432
433 set_transient( self::REFRESH_THROTTLE_TRANSIENT, 1, DAY_IN_SECONDS );
434
435 $this->refresh_group_properties();
436 }
437
438 /**
439 * Push the current site profile onto the PostHog `site` group.
440 */
441 public function refresh_group_properties(): void {
442 $analytics = Analytics::instance();
443 $site_id = $analytics->get_site_id();
444
445 if ( '' === $site_id ) {
446 return;
447 }
448
449 $properties = ( new Analytics_Profile() )->get_group_properties();
450
451 // Leave the band somewhere uninstall.php can read it without the plugin.
452 if ( isset( $properties['order_count_band'] ) ) {
453 update_option( self::LAST_ORDER_BAND_OPTION, $properties['order_count_band'], false );
454 }
455
456 $analytics->group( 'site', $site_id, $properties );
457 }
458
459 /**
460 * Properties describing how much the site had invested when it churned.
461 *
462 * The order count is banded like every other count we report. Churn
463 * analysis only asks whether they left with nothing or left with a real
464 * trading history, and a band answers that without carrying an exact
465 * figure out of the store.
466 *
467 * @return array<string, mixed>
468 */
469 private function get_churn_properties(): array {
470 $metrics = ( new Landing_Profile() )->get_metrics();
471
472 return array(
473 'days_since_install' => (int) ( $metrics['days_since_install'] ?? 0 ),
474 'order_count_band' => Analytics_Profile::band( (int) ( $metrics['order_count'] ?? 0 ) ),
475 );
476 }
477
478 /**
479 * Queue a lifecycle event for the next admin page load.
480 *
481 * Always queued, never sent inline — and that is not just about consent.
482 * Install and upgrade both run before the plugin is fully booted: the
483 * activation hook fires in a request where `plugins_loaded` has already
484 * passed, so Init never ran and `wcpos-functions.php` is not loaded, and
485 * the upgrade check runs before `new Init()`. Capturing from either point
486 * would call `wcpos_get_site_uuid()` before it exists and fatal the
487 * activation. Queueing needs nothing but the options API.
488 *
489 * The event carries only its own properties. The environment and store
490 * snapshot lives on the `site` group, which flush_pending() refreshes in
491 * the same pass — no need to copy it onto every event.
492 *
493 * @param string $event Event name.
494 * @param array $properties Event properties.
495 */
496 private function record( string $event, array $properties = array() ): void {
497 // An explicit "no" is an answer, not a delay. Read the persisted value:
498 // nothing should be queued about a merchant who declined, even if a
499 // settings filter presents a different answer.
500 if ( 'denied' === Settings::instance()->raw_tracking_consent() ) {
501 return;
502 }
503
504 $pending = get_option( self::PENDING_OPTION );
505 if ( ! \is_array( $pending ) ) {
506 $pending = array();
507 }
508
509 if ( \count( $pending ) >= self::MAX_PENDING ) {
510 return;
511 }
512
513 $pending[] = array(
514 'event' => $event,
515 'properties' => $properties,
516 'timestamp' => gmdate( 'c' ),
517 );
518
519 update_option( self::PENDING_OPTION, $pending, false );
520 }
521 }
522