Skip to content

fix(tracking): resolve sendBeacon + REST interaction tracking regression#179

Merged
parhumm merged 1 commit intodevelopmentfrom
fix/174-sendbeacon-rest-overwrite
Mar 12, 2026
Merged

fix(tracking): resolve sendBeacon + REST interaction tracking regression#179
parhumm merged 1 commit intodevelopmentfrom
fix/174-sendbeacon-rest-overwrite

Conversation

@parhumm
Copy link
Copy Markdown
Contributor

@parhumm parhumm commented Mar 12, 2026

Summary

Fixes #174 — outbound link clicks, downloads, and exit-time updates were silently broken when using REST transport with navigator.sendBeacon().

Two root causes found and fixed:

  • TrackingRestController overwrite (src/Controllers/Rest/TrackingRestController.php): The handler unconditionally replaced wp_slimstat::$raw_post_array with $request->get_params(). Since WordPress REST API cannot parse text/plain bodies (what sendBeacon uses), this destroyed the correctly-parsed data from php://input at plugin init. Fixed by merging init-parsed data with REST params.

  • Query builder SET/WHERE parameter misalignment (src/Utils/Query.php): The valuesToPrepare array was shared between set() and where() methods. In Storage::updateRow(), where() is chained before set(), so WHERE values preceded SET values in the flat array — but SQL requires SET values before WHERE values. This caused wpdb->prepare() to assign values to wrong placeholders (e.g., dt_out got the URL, outbound_resource got the IP hash). Fixed by using a separate setValuesToPrepare array merged in correct SQL order.

Test plan

  • Unit tests: 16 assertions covering merge behavior for sendBeacon, XHR, and empty-init scenarios
  • All existing unit tests pass (83 assertions across 6 test files)
  • E2E test: simulates sendBeacon text/plain POST with interaction payload, asserts outbound_resource and dt_out populated in DB
  • E2E regression guard: pageview via XHR with REST transport still works

Summary by CodeRabbit

  • New Features

    • Enhanced sendBeacon tracking for outbound link clicks with proper REST payload handling
  • Improvements

    • Improved REST parameter sanitization and secure data merging from multiple sources
    • Better handling of tracking data preservation across different transport methods
  • Tests

    • Added comprehensive test coverage for REST tracking parameter handling
    • Added end-to-end tests validating sendBeacon interactions and outbound resource recording

…ion (#174)

Two bugs prevented outbound link clicks, downloads, and exit-time updates
from being recorded when using REST transport with sendBeacon:

1. TrackingRestController unconditionally overwrote the correctly-parsed
   raw_post_array with incomplete REST params (text/plain bodies can't be
   parsed by WordPress REST API). Fixed by merging init-parsed data with
   REST params instead of replacing.

2. Query builder's shared valuesToPrepare array caused SET and WHERE
   parameter values to be misaligned in UPDATE queries — the WHERE value
   was pushed first but SQL expects SET values first. Fixed by separating
   SET values into their own array and merging in correct SQL order.

Includes unit tests for the REST controller merge logic and E2E tests
that simulate sendBeacon interaction tracking end-to-end.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 12, 2026

📝 Walkthrough

Walkthrough

This PR fixes outbound link tracking when using REST API with sendBeacon by merging REST parameters with data already parsed from php://input, preventing overwrites with empty payloads. It includes comprehensive unit and end-to-end test coverage, a Query utility fix for prepared statement parameter ordering, and test infrastructure updates.

Changes

Cohort / File(s) Summary
Configuration & Scripts
composer.json
Adds new test script test:rest-controller and includes it in the test:all aggregation.
REST Controller Bug Fix
src/Controllers/Rest/TrackingRestController.php
Modifies REST tracking handler to merge REST parameters with already-parsed init data (from php://input) instead of overwriting, preserving sendBeacon text/plain payloads while sanitizing specific scalar keys and maintaining extension compatibility.
Query Utility Refactoring
src/Utils/Query.php
Separates SET clause values from WHERE clause values in prepared statements by introducing setValuesToPrepare array, ensuring correct placeholder binding order (SET before WHERE).
E2E Test Infrastructure
tests/e2e/helpers/setup.ts
Adds new getLatestStatFull() helper function to query full row data from wp_slim_stats table with additional fields (resource, outbound_resource, dt_out, country, city).
E2E Test Suite
tests/e2e/sendbeacon-interaction.spec.ts
Introduces Playwright-based test suite validating outbound link tracking via sendBeacon with proper database persistence, including setup/teardown hooks and regression guards for XHR pageview tracking.
Test Stubs
tests/stubs/slimstat-tracker-stub.php, tests/stubs/tracking-stubs.php
Provides test doubles for SlimStat and WordPress classes/functions (WP_REST_Request, wp_slimstat, WP_Error, sanitize_text_field, wp_unslash, rest_ensure_response, etc.) with conditional guards against redefinition.
Unit Tests
tests/tracking-rest-controller-test.php
Adds test coverage for REST controller behavior including sanitize_integer_param edge cases and handle_tracking merge scenarios (sendBeacon with preserved init data, XHR with REST override, empty init with REST params).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops through REST APIs,
With sendBeacon flying through the skies,
No more lost data in the merge,
Test stubs stand guard at the verge,
Tracking flows where outbound links arise! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.79% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: fixing a sendBeacon + REST interaction tracking regression, which is the primary objective of the PR.
Linked Issues check ✅ Passed The PR fully addresses the coding requirements from issue #174: merges init-parsed data with REST params in TrackingRestController (#174), fixes SET/WHERE parameter order in Query builder, and includes comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the sendBeacon + REST regression: controller merge logic, query parameter ordering, test stubs, unit tests, and E2E tests. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/174-sendbeacon-rest-overwrite

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
src/Controllers/Rest/TrackingRestController.php (1)

136-144: Consider adding a comment explaining the scalar_keys purpose.

The scalar_keys array includes params like 'lt', 'dc', 'ob' that aren't in the route args. This is intentional—these are interaction tracking params that arrive via sendBeacon text/plain, bypassing REST route validation. A brief comment clarifying this distinction would improve maintainability.

Add clarifying comment
-            // Sanitize known scalar keys from REST params (skip when empty — common for sendBeacon)
+            // Sanitize known scalar keys from REST params (skip when empty — common for sendBeacon).
+            // These include interaction params (lt, dc, ob) that arrive via text/plain and bypass
+            // REST route validation, as well as params defined in route args.
             if (!empty($rest_params)) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Controllers/Rest/TrackingRestController.php` around lines 136 - 144, Add
a short clarifying comment above the scalar_keys array in the
TrackingRestController (near the rest_params sanitization block) explaining that
these keys (e.g., 'lt', 'dc', 'ob', etc.) are interaction/tracking parameters
that can arrive via sendBeacon with text/plain and therefore bypass REST route
validation; mention that they’re intentionally sanitized here using
sanitize_text_field and wp_unslash before further processing.
tests/stubs/slimstat-tracker-stub.php (1)

6-19: Stub signatures don't match production implementations.

Based on the relevant code snippets provided:

  1. Tracker::slimtrack_ajax() (Line 9): Production returns void (implicitly via Ajax::handle()), but stub returns '1'. Tests relying on the return value won't reflect production behavior.

  2. ConsentHandler::handleBannerConsent() (Line 17): Production signature is (bool $return_json = true, array $data = []), but stub uses ($json = true, $data = null). The $data = null default vs array $data = [] type mismatch could cause different behavior if the stub is ever invoked with default arguments.

For unit tests that only verify the controller's merge logic, this is acceptable. However, if future tests rely on these stubs to validate return values or parameter handling, they may produce false positives.

Align stub signatures with production
 namespace SlimStat\Tracker {
     if (!class_exists(__NAMESPACE__ . '\Tracker')) {
         class Tracker {
-            public static function slimtrack_ajax() { return '1'; }
+            public static function slimtrack_ajax(): void {}
         }
     }
 }

 namespace SlimStat\Services\Privacy {
     if (!class_exists(__NAMESPACE__ . '\ConsentHandler')) {
         class ConsentHandler {
-            public static function handleBannerConsent($json = true, $data = null) { return true; }
+            public static function handleBannerConsent(bool $return_json = true, array $data = []) { return true; }
         }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/stubs/slimstat-tracker-stub.php` around lines 6 - 19, The stub
signatures diverge from production: update
SlimStat\Tracker\Tracker::slimtrack_ajax to match the production behavior (no
return / void) instead of returning '1', and change
SlimStat\Services\Privacy\ConsentHandler::handleBannerConsent signature to use
(bool $return_json = true, array $data = []) with matching default values and
types (avoid $data = null); adjust the stub bodies to mirror production
side-effects/return expectations so tests don't get false positives.
tests/e2e/helpers/setup.ts (1)

270-276: Type declaration for dt_out may not match database reality.

The return type declares dt_out: number, but this column can be NULL in the database when no exit time has been recorded (e.g., for pageviews without an outbound click). Consider using dt_out: number | null to accurately reflect the database schema.

Proposed type fix
-export async function getLatestStatFull(testMarker: string): Promise<{ id: number; resource: string; outbound_resource: string | null; dt_out: number; country: string; city: string } | null> {
+export async function getLatestStatFull(testMarker: string): Promise<{ id: number; resource: string; outbound_resource: string | null; dt_out: number | null; country: string; city: string } | null> {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/helpers/setup.ts` around lines 270 - 276, The return type of
getLatestStatFull declares dt_out as number but the wp_slim_stats.dt_out column
can be NULL; update the function's Promise return type to use dt_out: number |
null and ensure any call sites handle a nullable dt_out (identify function
getLatestStatFull, the dt_out field in the SELECT, and getPool()/wp_slim_stats
as relevant symbols).
tests/stubs/tracking-stubs.php (1)

22-31: get_body_params() stub always returns empty array.

The production code (TrackingRestController.php:167-172) uses get_body_params() as a fallback for extracting banner_consent and banner_consent_nonce from POST body. The stub returning [] means this fallback path cannot be tested. If banner consent testing becomes necessary, this stub will need enhancement.

Optional: Make body_params configurable
 if (!class_exists('WP_REST_Request')) {
     class WP_REST_Request {
         private array $params;
-        public function __construct(array $params = []) { $this->params = $params; }
+        private array $body_params;
+        public function __construct(array $params = [], array $body_params = []) {
+            $this->params = $params;
+            $this->body_params = $body_params;
+        }
         public function get_params(): array { return $this->params; }
         public function get_param(string $key) { return $this->params[$key] ?? null; }
-        public function get_body_params(): array { return []; }
+        public function get_body_params(): array { return $this->body_params; }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/stubs/tracking-stubs.php` around lines 22 - 31, The WP_REST_Request
test stub's get_body_params() always returns an empty array, preventing tests
from exercising the fallback path in TrackingRestController::get_body_params
usage; modify the WP_REST_Request stub class to accept and store body params
(e.g., add a constructor parameter or setter for $bodyParams and a private array
$bodyParams property) and update get_body_params() to return that stored array
so tests can supply POST body keys like banner_consent and banner_consent_nonce
when needed; keep existing get_param/get_params behavior intact.
tests/e2e/sendbeacon-interaction.spec.ts (1)

63-63: Consider replacing fixed timeout with dynamic wait.

The waitForTimeout(5000) is a brittle approach that may cause flakiness in CI environments with varying load. Consider polling the database until the stat appears or waiting for a specific network response.

Alternative: Poll for stat to appear
// Replace fixed timeout with polling
let pageviewStat = null;
for (let i = 0; i < 50; i++) {
  pageviewStat = await getLatestStatFull(marker);
  if (pageviewStat && pageviewStat.id > 0) break;
  await page.waitForTimeout(100);
}
expect(pageviewStat).not.toBeNull();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/sendbeacon-interaction.spec.ts` at line 63, The test uses a brittle
fixed wait via page.waitForTimeout(5000); replace it with a dynamic wait that
polls for the expected stat instead of sleeping: repeatedly call
getLatestStatFull(marker) (or wait for the specific network response) until
pageviewStat shows the expected id/value or a short timeout is reached, then
assert pageviewStat is not null; update the test to remove page.waitForTimeout
and use the polling loop (or Playwright's waitForResponse) to avoid flakiness.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/Controllers/Rest/TrackingRestController.php`:
- Around line 136-144: Add a short clarifying comment above the scalar_keys
array in the TrackingRestController (near the rest_params sanitization block)
explaining that these keys (e.g., 'lt', 'dc', 'ob', etc.) are
interaction/tracking parameters that can arrive via sendBeacon with text/plain
and therefore bypass REST route validation; mention that they’re intentionally
sanitized here using sanitize_text_field and wp_unslash before further
processing.

In `@tests/e2e/helpers/setup.ts`:
- Around line 270-276: The return type of getLatestStatFull declares dt_out as
number but the wp_slim_stats.dt_out column can be NULL; update the function's
Promise return type to use dt_out: number | null and ensure any call sites
handle a nullable dt_out (identify function getLatestStatFull, the dt_out field
in the SELECT, and getPool()/wp_slim_stats as relevant symbols).

In `@tests/e2e/sendbeacon-interaction.spec.ts`:
- Line 63: The test uses a brittle fixed wait via page.waitForTimeout(5000);
replace it with a dynamic wait that polls for the expected stat instead of
sleeping: repeatedly call getLatestStatFull(marker) (or wait for the specific
network response) until pageviewStat shows the expected id/value or a short
timeout is reached, then assert pageviewStat is not null; update the test to
remove page.waitForTimeout and use the polling loop (or Playwright's
waitForResponse) to avoid flakiness.

In `@tests/stubs/slimstat-tracker-stub.php`:
- Around line 6-19: The stub signatures diverge from production: update
SlimStat\Tracker\Tracker::slimtrack_ajax to match the production behavior (no
return / void) instead of returning '1', and change
SlimStat\Services\Privacy\ConsentHandler::handleBannerConsent signature to use
(bool $return_json = true, array $data = []) with matching default values and
types (avoid $data = null); adjust the stub bodies to mirror production
side-effects/return expectations so tests don't get false positives.

In `@tests/stubs/tracking-stubs.php`:
- Around line 22-31: The WP_REST_Request test stub's get_body_params() always
returns an empty array, preventing tests from exercising the fallback path in
TrackingRestController::get_body_params usage; modify the WP_REST_Request stub
class to accept and store body params (e.g., add a constructor parameter or
setter for $bodyParams and a private array $bodyParams property) and update
get_body_params() to return that stored array so tests can supply POST body keys
like banner_consent and banner_consent_nonce when needed; keep existing
get_param/get_params behavior intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 184fe820-2ef4-4df0-848b-b0066aa326af

📥 Commits

Reviewing files that changed from the base of the PR and between 3089ba0 and 93d41c3.

📒 Files selected for processing (8)
  • composer.json
  • src/Controllers/Rest/TrackingRestController.php
  • src/Utils/Query.php
  • tests/e2e/helpers/setup.ts
  • tests/e2e/sendbeacon-interaction.spec.ts
  • tests/stubs/slimstat-tracker-stub.php
  • tests/stubs/tracking-stubs.php
  • tests/tracking-rest-controller-test.php

@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 12, 2026

Test Summary

Unit Tests — 83/83 pass

Suite Assertions Status
license-tag-gating 14
resolve-geolocation-provider 24
geoservice-provider-resolution 16
legacy-sync-mapping 7
lazy-migration 6
tracking-rest-controller (new) 16

New unit test cases for TrackingRestController::handle_tracking():

  • sendBeacon scenario: init-parsed raw_post_array preserved when REST params empty
  • XHR scenario: REST-sanitized params override overlapping init-parsed keys
  • Empty init scenario: REST params used directly when raw_post_array is empty
  • Action forced to slimtrack in all scenarios

E2E Tests — 2/2 pass (21.3s)

Test Duration Status
outbound link click via sendBeacon populates outbound_resource and dt_out 10.6s
pageview via XHR still works with REST transport (regression guard) 9.2s

E2E methodology:

  • Sets tracking_request_method=rest and gdpr_enabled=off
  • Visits a frontend page with unique marker, waits for pageview tracking
  • Captures pageview ID with checksum from REST response
  • Sends text/plain;charset=UTF-8 POST to REST endpoint (simulating navigator.sendBeacon)
  • Payload includes base64-encoded outbound URL, position, and note matching tracker format
  • Asserts outbound_resource contains external URL and dt_out > 0 in database

@parhumm
Copy link
Copy Markdown
Contributor Author

parhumm commented Mar 12, 2026

Manual Human QA Checklist

Prerequisites

  • Fresh install or update to this branch
  • Default settings (REST transport, javascript_mode=off)
  • GDPR Compliance Mode: off (or consent granted)
  • Browser DevTools Network tab open, filtered to slimstat or slim

1. Outbound Link Tracking (Primary Fix)

  • Visit any frontend page
  • Click an external link (e.g., a link to google.com or any external site)
  • Go to SlimStat > Real-Time — verify the outbound destination URL appears in the access log
  • Go to SlimStat > Reports > Recent External Links — verify the URL is listed

2. Download Tracking

  • Add a link to a downloadable file (PDF, ZIP, etc.) on a page
  • Click the download link
  • Verify the download appears in Real-Time and in the download reports

3. Exit Time (dt_out)

  • Visit a frontend page, stay for 10+ seconds
  • Navigate away or close the tab
  • Check the database: SELECT dt_out FROM wp_slim_stats ORDER BY id DESC LIMIT 5
  • Verify dt_out is non-zero for the visit

4. Pageview Tracking (Regression Check)

  • Visit 3-4 different frontend pages
  • Verify each pageview appears in Real-Time with correct page URL
  • Verify visitor IP, browser, and country are populated (not blank or swapped)

5. Database Value Integrity

  • Run: SELECT id, resource, outbound_resource, dt_out, ip FROM wp_slim_stats ORDER BY id DESC LIMIT 10
  • Verify columns contain the correct data types:
    • resource = page URL (e.g., /?p=...)
    • outbound_resource = external URL (e.g., https://google.com/...)
    • dt_out = unix timestamp (integer)
    • ip = hashed IP (hex string)
  • No column value swapping (this was the Query builder bug — values ending up in wrong columns)

6. Transport Methods

  • With tracking_request_method = rest — repeat tests 1 and 4
  • With tracking_request_method = ajax — repeat tests 1 and 4
  • Both should work correctly

7. Browser Compatibility

  • Chrome — outbound link tracked
  • Firefox — outbound link tracked
  • Safari — outbound link tracked (sendBeacon support varies)

Expected Results

Scenario Before Fix After Fix
Outbound link in Real-Time Missing Shows destination URL
dt_out in database 0 or wrong value Correct unix timestamp
UPDATE query column alignment Values in wrong columns Values in correct columns
Pageview tracking (XHR) Works Still works

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant