PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.1
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.1
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 / Lifecycle_Events.php

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

518 lines 17.4 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 if ( 'denied' === Settings::instance()->tracking_consent() ) {
347 delete_option( self::PENDING_OPTION );
348 }
349
350 return;
351 }
352
353 $pending = get_option( self::PENDING_OPTION );
354 if ( empty( $pending ) || ! \is_array( $pending ) ) {
355 return;
356 }
357
358 // Clear first. A failed send is not worth retrying forever, and leaving
359 // the queue populated would re-send on every admin page load.
360 delete_option( self::PENDING_OPTION );
361
362 $analytics = Analytics::instance();
363
364 foreach ( $pending as $entry ) {
365 if ( ! \is_array( $entry ) || empty( $entry['event'] ) ) {
366 continue;
367 }
368
369 $analytics->capture(
370 (string) $entry['event'],
371 \is_array( $entry['properties'] ?? null ) ? $entry['properties'] : array(),
372 '',
373 isset( $entry['timestamp'] ) ? (string) $entry['timestamp'] : ''
374 );
375 }
376
377 // Queued events describe the install, so the site profile that goes with
378 // them is worth sending in the same pass.
379 $this->refresh_group_properties();
380 }
381
382 /**
383 * Schedule the daily group property refresh if consent allows it.
384 */
385 public function maybe_schedule_refresh(): void {
386 if ( ! Analytics::instance()->is_enabled() ) {
387 // Consent can be withdrawn — stop refreshing if it has been. Guarded
388 // so an opted-out site does not touch the cron array on every load.
389 if ( wp_next_scheduled( self::REFRESH_HOOK ) ) {
390 $this->clear_schedule();
391 }
392
393 return;
394 }
395
396 if ( ! wp_next_scheduled( self::REFRESH_HOOK ) ) {
397 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::REFRESH_HOOK );
398 }
399 }
400
401 /**
402 * Clear the scheduled refresh.
403 */
404 public function clear_schedule(): void {
405 wp_clear_scheduled_hook( self::REFRESH_HOOK );
406 }
407
408 /**
409 * Transient guarding the on-page-load group refresh.
410 *
411 * @var string
412 */
413 const REFRESH_THROTTLE_TRANSIENT = 'wcpos_analytics_group_refreshed';
414
415 /**
416 * Refresh the site profile from a page load, at most once a day.
417 *
418 * The scheduled refresh is the primary path; this is the fallback for
419 * installs where WP-Cron is unreliable or disabled. Throttled because the
420 * profile is a slow-moving description of the site, not a page-view metric.
421 */
422 public function maybe_refresh_group_properties(): void {
423 if ( ! Analytics::instance()->is_enabled() ) {
424 return;
425 }
426
427 if ( false !== get_transient( self::REFRESH_THROTTLE_TRANSIENT ) ) {
428 return;
429 }
430
431 set_transient( self::REFRESH_THROTTLE_TRANSIENT, 1, DAY_IN_SECONDS );
432
433 $this->refresh_group_properties();
434 }
435
436 /**
437 * Push the current site profile onto the PostHog `site` group.
438 */
439 public function refresh_group_properties(): void {
440 $analytics = Analytics::instance();
441 $site_id = $analytics->get_site_id();
442
443 if ( '' === $site_id ) {
444 return;
445 }
446
447 $properties = ( new Analytics_Profile() )->get_group_properties();
448
449 // Leave the band somewhere uninstall.php can read it without the plugin.
450 if ( isset( $properties['order_count_band'] ) ) {
451 update_option( self::LAST_ORDER_BAND_OPTION, $properties['order_count_band'], false );
452 }
453
454 $analytics->group( 'site', $site_id, $properties );
455 }
456
457 /**
458 * Properties describing how much the site had invested when it churned.
459 *
460 * The order count is banded like every other count we report. Churn
461 * analysis only asks whether they left with nothing or left with a real
462 * trading history, and a band answers that without carrying an exact
463 * figure out of the store.
464 *
465 * @return array<string, mixed>
466 */
467 private function get_churn_properties(): array {
468 $metrics = ( new Landing_Profile() )->get_metrics();
469
470 return array(
471 'days_since_install' => (int) ( $metrics['days_since_install'] ?? 0 ),
472 'order_count_band' => Analytics_Profile::band( (int) ( $metrics['order_count'] ?? 0 ) ),
473 );
474 }
475
476 /**
477 * Queue a lifecycle event for the next admin page load.
478 *
479 * Always queued, never sent inline — and that is not just about consent.
480 * Install and upgrade both run before the plugin is fully booted: the
481 * activation hook fires in a request where `plugins_loaded` has already
482 * passed, so Init never ran and `wcpos-functions.php` is not loaded, and
483 * the upgrade check runs before `new Init()`. Capturing from either point
484 * would call `wcpos_get_site_uuid()` before it exists and fatal the
485 * activation. Queueing needs nothing but the options API.
486 *
487 * The event carries only its own properties. The environment and store
488 * snapshot lives on the `site` group, which flush_pending() refreshes in
489 * the same pass — no need to copy it onto every event.
490 *
491 * @param string $event Event name.
492 * @param array $properties Event properties.
493 */
494 private function record( string $event, array $properties = array() ): void {
495 // An explicit "no" is an answer, not a delay.
496 if ( 'denied' === Settings::instance()->tracking_consent() ) {
497 return;
498 }
499
500 $pending = get_option( self::PENDING_OPTION );
501 if ( ! \is_array( $pending ) ) {
502 $pending = array();
503 }
504
505 if ( \count( $pending ) >= self::MAX_PENDING ) {
506 return;
507 }
508
509 $pending[] = array(
510 'event' => $event,
511 'properties' => $properties,
512 'timestamp' => gmdate( 'c' ),
513 );
514
515 update_option( self::PENDING_OPTION, $pending, false );
516 }
517 }
518