Fix 500 error on REST API and admin-ajax tracking endpoints#218
Fix 500 error on REST API and admin-ajax tracking endpoints#218parhumm merged 4 commits intodevelopmentfrom
Conversation
Refactored Ajax::handle() to separate exit behavior from processing logic: - Added new Ajax::process() method that returns result instead of calling exit() - Ajax::handle() now wraps process() and calls exit() for admin-ajax.php - Tracker::slimtrack_ajax() now returns result from Ajax::process() - TrackingRestController simplified - no longer needs output buffering - RestApiManager::handleAdblockTracking() now outputs result and exits The previous implementation called exit() inside Ajax::handle(), which terminated the script before the REST API could return a proper response. This caused 500 errors on both /wp-json/slimstat/v1/hit and admin-ajax.php endpoints when the REST handler couldn't complete normally. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughReworks tracking flow to return values instead of performing direct exits: adds Ajax::process(), makes Tracker::slimtrack_ajax() return that result, updates REST and adblock-bypass handlers to consume/emit returned values and return a WP_Error (400) when tracking fails. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant REST as RestController
participant Tracker
participant Ajax
participant DB
Client->>REST: POST/GET tracking request
REST->>Tracker: Tracker::slimtrack_ajax()
Tracker->>Ajax: Ajax::process()
Ajax->>DB: validate & insert tracking row
DB-->>Ajax: inserted id
Ajax-->>Tracker: return id or checksum
Tracker-->>REST: return id/checksum
REST-->>Client: JSON response (id/checksum) or WP_Error (400)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
📝 Coding Plan
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. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/Controllers/Rest/TrackingRestController.php (1)
188-194:⚠️ Potential issue | 🔴 CriticalSuccessful REST hits are misclassified as failures.
Tracker::slimtrack_ajax()now returns checksum-formatted success values (e.g.,"<id>.<hash>"), but Line 192 only acceptsis_numeric($result). Valid successful hits therefore fall through to the 400WP_Errorpath.🐛 Proposed fix
- // Normalize to string numeric id if possible - if (is_numeric($result) && (int) $result > 0) { - return rest_ensure_response((string) $result); - } + // Accept both legacy numeric IDs and checksum-form IDs ("<id>.<sha256>") + $has_valid_tracking_id = false; + if (is_int($result) || ctype_digit((string) $result)) { + $has_valid_tracking_id = (int) $result > 0; + } elseif (is_string($result) && preg_match('/^([1-9]\d*)\.[a-f0-9]{64}$/i', $result, $matches)) { + $has_valid_tracking_id = (int) $matches[1] > 0; + } + + if ($has_valid_tracking_id) { + return rest_ensure_response((string) $result); + }🤖 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 188 - 194, Tracker::slimtrack_ajax() now returns checksum-formatted success strings like "<id>.<hash>" so the current numeric-only check rejects valid successes; update the condition handling the $result (the block that currently uses is_numeric and (int)$result) to also accept checksum-formatted strings by detecting a regex like /^(\d+)\.[0-9a-fA-F]+$/ and extracting the leading numeric id, verifying (int)$id > 0, then return rest_ensure_response((string)$id) (keep existing numeric-path behavior for pure numeric results); touch the code around the Tracker::slimtrack_ajax() call and the rest_ensure_response return to implement this extra branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@src/Controllers/Rest/TrackingRestController.php`:
- Around line 188-194: Tracker::slimtrack_ajax() now returns checksum-formatted
success strings like "<id>.<hash>" so the current numeric-only check rejects
valid successes; update the condition handling the $result (the block that
currently uses is_numeric and (int)$result) to also accept checksum-formatted
strings by detecting a regex like /^(\d+)\.[0-9a-fA-F]+$/ and extracting the
leading numeric id, verifying (int)$id > 0, then return
rest_ensure_response((string)$id) (keep existing numeric-path behavior for pure
numeric results); touch the code around the Tracker::slimtrack_ajax() call and
the rest_ensure_response return to implement this extra branch.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4681aca5-5880-47d6-a503-b1a46ff0d08a
📒 Files selected for processing (4)
src/Controllers/Rest/TrackingRestController.phpsrc/Providers/RestApiManager.phpsrc/Tracker/Ajax.phpsrc/Tracker/Tracker.php
Adds 8 E2E tests covering REST API, admin-AJAX, and adblock bypass tracking methods plus fallback chain and cross-method consistency. Validates the PR #218 fix (REST returns non-500 response).
E2E Test Results — Tracking Request MethodsBranch: Results: 8/8 PASS
Code VerificationAll 4 changed files verified:
|
Ajax::process() returns "<id>.<hmac>" via Utils::getValueWithChecksum(), not a plain numeric ID. The is_numeric() check rejected these valid success responses, causing the REST endpoint to always return 400. Added regex branch to extract the numeric ID from checksum strings.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
tests/e2e/tracking-request-methods.spec.ts (2)
333-338: Redundant conditional assertion.The
if (ajaxFallbackUsed) { expect(...).toBe(true) }block is always true when entered, making the expect redundant. Consider either removing the conditional wrapper or making the assertion unconditional if AJAX fallback is the expected behavior.♻️ Simplify the assertion
const stat = await waitForStatRow(marker, 15_000); expect(stat).toBeTruthy(); expect(stat!.resource).toContain(marker); // When REST is blocked, AJAX should be used as fallback // (unless server-side tracking captured it first) - if (ajaxFallbackUsed) { - expect(ajaxFallbackUsed).toBe(true); - } + // Note: server-side tracking may capture the hit before client fallback triggers, + // so we only verify the pageview was recorded, not necessarily via AJAX.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/e2e/tracking-request-methods.spec.ts` around lines 333 - 338, The conditional wrapper around the assertion is redundant: remove the surrounding "if (ajaxFallbackUsed) { ... }" and make the assertion unconditional so the test directly asserts expect(ajaxFallbackUsed).toBe(true); locate the check using the ajaxFallbackUsed variable in the test case (in tests/e2e/tracking-request-methods.spec.ts) and replace the if-block with a single unconditional expect call.
124-154: Test title doesn't match assertions.The test is titled "REST response body contains valid tracking ID with checksum" but the assertion only verifies the response doesn't contain "Internal Server Error". It doesn't actually validate that the response contains a numeric ID or checksum format.
Consider either updating the test title to match what it actually tests, or adding an assertion that validates the response format:
♻️ Option 1: Update title to match behavior
- test('REST response body contains valid tracking ID with checksum', async ({ page }) => { + test('REST response body does not contain server error', async ({ page }) => {♻️ Option 2: Add format validation assertion
// If we captured the response, verify it contains a numeric ID // REST response wraps the result in JSON — the body should contain a numeric string if (restResponseBody) { // Response should be parseable (not a 500 error page) expect(restResponseBody).not.toContain('Internal Server Error'); + // Response should contain a numeric tracking ID + const parsed = JSON.parse(restResponseBody); + expect(typeof parsed === 'string' || typeof parsed === 'number').toBe(true); + expect(parseInt(String(parsed), 10)).toBeGreaterThan(0); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/e2e/tracking-request-methods.spec.ts` around lines 124 - 154, The test named "REST response body contains valid tracking ID with checksum" currently only checks that restResponseBody doesn't contain 'Internal Server Error'; update behavior to match the title by parsing restResponseBody (captured in the page.on('response') handler) and asserting the tracking ID and checksum format (e.g., numeric ID and expected checksum pattern) are present, or alternatively change the test title to accurately reflect the current assertion; reference the test function (the test(...) block), the restResponseBody variable, and waitForStatRow(marker) to locate where to add the parsing/format assertion or rename the test.src/Controllers/Rest/TrackingRestController.php (1)
188-206: Consider using Yoda conditions for comparisons.The coding guidelines specify using Yoda conditions (
if ( 'value' === $var )). Lines 192 and 197 use standard comparison order.♻️ Yoda style refactor
- if (is_numeric($result) && (int) $result > 0) { + if (is_numeric($result) && 0 < (int) $result) { return rest_ensure_response((string) $result); } // Success: checksum-formatted string "<id>.<hash>" from Utils::getValueWithChecksum() - if (is_string($result) && preg_match('/^(\d+)\.[0-9a-fA-F]+$/', $result, $matches) && (int) $matches[1] > 0) { + if (is_string($result) && preg_match('/^(\d+)\.[0-9a-fA-F]+$/', $result, $matches) && 0 < (int) $matches[1]) { return rest_ensure_response($matches[1]); }As per coding guidelines: "Use Yoda Conditions:
if ( 'value' === $var )"🤖 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 188 - 206, The numeric comparisons in the tracking response handling should use Yoda conditions: update the checks that currently read (int) $result > 0 and (int) $matches[1] > 0 to place the literal first (e.g. 0 < (int) $result and 0 < (int) $matches[1]) in the block around Tracker::slimtrack_ajax(), keeping the same surrounding guards (is_numeric($result), is_string($result) && preg_match(..., $matches)) and returning via rest_ensure_response or the WP_Error unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/Controllers/Rest/TrackingRestController.php`:
- Around line 196-199: The REST handler in TrackingRestController currently
strips the checksum and returns only the numeric ID (it returns $matches[1]) but
the consent upgrade flow (ConsentHandler::... using
Utils::getValueWithoutChecksum()) requires the full "<id>.<hash>" string;
restore the original checksum-formatted string by returning the entire matched
result (the full "<id>.<hash>" from Utils::getValueWithChecksum()) instead of
just the numeric capture so that Utils::getValueWithoutChecksum() can validate
and extract the ID correctly; locate the conditional that uses preg_match and
rest_ensure_response and change the returned value to the full checksum string
produced by the regex/match.
---
Nitpick comments:
In `@src/Controllers/Rest/TrackingRestController.php`:
- Around line 188-206: The numeric comparisons in the tracking response handling
should use Yoda conditions: update the checks that currently read (int) $result
> 0 and (int) $matches[1] > 0 to place the literal first (e.g. 0 < (int) $result
and 0 < (int) $matches[1]) in the block around Tracker::slimtrack_ajax(),
keeping the same surrounding guards (is_numeric($result), is_string($result) &&
preg_match(..., $matches)) and returning via rest_ensure_response or the
WP_Error unchanged.
In `@tests/e2e/tracking-request-methods.spec.ts`:
- Around line 333-338: The conditional wrapper around the assertion is
redundant: remove the surrounding "if (ajaxFallbackUsed) { ... }" and make the
assertion unconditional so the test directly asserts
expect(ajaxFallbackUsed).toBe(true); locate the check using the ajaxFallbackUsed
variable in the test case (in tests/e2e/tracking-request-methods.spec.ts) and
replace the if-block with a single unconditional expect call.
- Around line 124-154: The test named "REST response body contains valid
tracking ID with checksum" currently only checks that restResponseBody doesn't
contain 'Internal Server Error'; update behavior to match the title by parsing
restResponseBody (captured in the page.on('response') handler) and asserting the
tracking ID and checksum format (e.g., numeric ID and expected checksum pattern)
are present, or alternatively change the test title to accurately reflect the
current assertion; reference the test function (the test(...) block), the
restResponseBody variable, and waitForStatRow(marker) to locate where to add the
parsing/format assertion or rename the test.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: b9fe08fc-be1f-4df5-946f-dde2fe36235a
📒 Files selected for processing (2)
src/Controllers/Rest/TrackingRestController.phptests/e2e/tracking-request-methods.spec.ts
The REST handler was stripping the checksum and returning only the bare numeric ID. The JS tracker stores the response as params.id and sends it back on subsequent requests (events, consent upgrade), where Utils::getValueWithoutChecksum() requires the full "<id>.<hash>" format. Also applies Yoda conditions per WP coding standards, removes redundant conditional assertion in fallback test, and strengthens REST response test to validate checksum format.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/Controllers/Rest/TrackingRestController.php (1)
203-208: Consider returning semantically correct HTTP status codes instead of generic 400.The current implementation returns 400 for all non-success cases, but
Ajax.process()returns distinct negative codes with semantic meaning:
Return Value Meaning Current Suggested -429Rate limited 400 429 -204Tracking disabled 400 204 0Tracking failed 400 400 ✓ Returning 400 for rate limiting may cause the JS tracker to retry aggressively (treating it as a transient failure), and triggering fallback methods when tracking is server-side disabled is wasteful since those will also fail.
If the current behavior is intentional for fallback triggering, consider at minimum passing the original error code in the response data for debugging:
Suggested improvement
+ // Map negative error codes to appropriate HTTP status + if (is_int($result) && $result < 0) { + $error_code = abs($result); + // Preserve semantic meaning for rate limiting + if (429 === $error_code) { + return new \WP_Error( + 'slimstat_rate_limited', + esc_html__('[REST API] Rate limited.', 'wp-slimstat'), + ['status' => 429] + ); + } + // Tracking intentionally suppressed (disabled, opt-out, etc.) + if (204 === $error_code) { + return new \WP_REST_Response(null, 204); + } + } + // If no valid tracking ID detected, return a non-200 status to trigger fallback tracking methods return new \WP_Error( 'slimstat_tracking_failed', esc_html__('[REST API] Tracking failed, falling back to alternative methods.', 'wp-slimstat'), - ['status' => 400] + ['status' => 400, 'error_code' => is_int($result) ? $result : 0] );🤖 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 203 - 208, The REST handler in TrackingRestController (the block that currently returns new \WP_Error('slimstat_tracking_failed', ..., ['status' => 400])) should map the internal Ajax.process() return codes to semantically correct HTTP statuses: return 429 when Ajax.process() yields -429 (rate limited), return 204 when it yields -204 (tracking disabled), and keep 400 for a generic failure (0); implement this mapping before constructing the \WP_Error so the ['status'] field uses the mapped HTTP status, and if you prefer to preserve the original code for debugging also include it in the error data (e.g., ['status'=>mapped, 'code'=>originalCode]) so the client can still see the original numeric result.
🤖 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 203-208: The REST handler in TrackingRestController (the block
that currently returns new \WP_Error('slimstat_tracking_failed', ..., ['status'
=> 400])) should map the internal Ajax.process() return codes to semantically
correct HTTP statuses: return 429 when Ajax.process() yields -429 (rate
limited), return 204 when it yields -204 (tracking disabled), and keep 400 for a
generic failure (0); implement this mapping before constructing the \WP_Error so
the ['status'] field uses the mapped HTTP status, and if you prefer to preserve
the original code for debugging also include it in the error data (e.g.,
['status'=>mapped, 'code'=>originalCode]) so the client can still see the
original numeric result.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 4858c18e-5077-4b82-bb34-545b696fa8f2
📒 Files selected for processing (2)
src/Controllers/Rest/TrackingRestController.phptests/e2e/tracking-request-methods.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/e2e/tracking-request-methods.spec.ts
parhumm
left a comment
There was a problem hiding this comment.
Code Review — Deep Impact Analysis
Reviewer: AI-assisted review (wp-pr-review)
Scope: Security, performance, backward compat, add-on ecosystem, cross-transport consistency
Files: 4 PHP + 1 E2E spec (4 commits on fix/rest-api-hit-500-error)
Verdict: ✅ APPROVE
No security, performance, or backward compatibility issues found. The refactor is clean and well-scoped.
Architecture Verification
| Check | Result |
|---|---|
Ajax::handle() caller audit |
Only wp_ajax_nopriv_slimtrack + wp_ajax_slimtrack hooks — ✅ correct (exits) |
Ajax::process() caller audit |
Only Tracker::slimtrack_ajax() — ✅ correct (returns) |
slimtrack_ajax() callers |
TrackingRestController (returns to REST framework) + RestApiManager::handleAdblockTracking() (echo+exit) — ✅ both handle return value correctly |
| PRO add-on callers | Grep across wp-slimstat-pro/ — zero references to Ajax::handle, Ajax::process, or slimtrack_ajax — ✅ no ecosystem impact |
Security Scan (Changed Files Only)
| Pattern | Files Scanned | Findings |
|---|---|---|
Superglobal access ($_SERVER, $_COOKIE) |
Ajax.php | All properly sanitized with sanitize_text_field(wp_unslash(...)) |
$wpdb queries |
Ajax.php | All use prepare() ✅ |
REST permission_callback |
TrackingRestController.php | __return_true — intentional for public analytics endpoint, documented in code |
Dangerous functions (eval, unserialize, etc.) |
All 4 files | None found |
is_admin() as auth |
All 4 files | Not used as authorization |
Checksum Data Flow Verification
The full round-trip was traced:
- Server →
Ajax::process()returnsUtils::getValueWithChecksum($id)→"42.abc123..." - REST handler →
rest_ensure_response($result)returns full checksum string ✅ - JS tracker →
params.id = xhr.responseTextstores"42.abc123..." - Subsequent request →
Ajax::process()line 111:Utils::getValueWithoutChecksum($data_js['id'])→explode('.', ...)expects 2 parts ✅ - Consent upgrade →
ConsentHandler::handleBannerConsent()→Utils::getValueWithoutChecksum($pageview_id_raw)✅ - Consent change →
ConsentChangeRestControllerline 187 →Utils::getValueWithoutChecksum($pageview_id_raw)✅
All 6 consumers of the checksum string are compatible with the full "id.hash" format. Returning bare IDs (as the original code did) would have broken steps 4-6.
Backward Compatibility
| Concern | Status |
|---|---|
Ajax::handle() signature |
Unchanged — thin wrapper, same public API |
Ajax::process() |
New method — additive, non-breaking |
Tracker::slimtrack_ajax() return type |
Changed from void to string|int — callers that ignored the return (like old admin-ajax) are unaffected |
Hook signatures (slimstat_track_exit_*, slimstat_track_success) |
Unchanged — fired at same points |
| PHP 7.4+ compat | No new syntax requiring PHP 8.0+ |
Performance
No new database queries, no new transient writes, no unbounded operations. The exit() → return refactor has zero performance overhead.
E2E Test Quality
8 tests covering all 3 transports + fallback + cross-method consistency. Test infrastructure is solid (marker-based DB correlation, waitForStatRow polling, setSlimstatOption via mu-plugin). Permalink lifecycle management for adblock bypass is correctly handled in beforeAll/afterAll.
Minor Observations (INFO — no action needed)
-
Adblock bypass
echo $result(RestApiManager.php:171): Echoes raw result (could be negative error code like-429). Not a security issue since the value comes from internal logic, but differs from REST path which wraps inrest_ensure_response(). Acceptable because adblock bypass must terminate early without REST framework. -
Legacy
Tracker::_get_value_with_checksum()(Tracker.php:376): Usesmd5()while the newUtils::getValueWithChecksum()useshash_hmac('sha256', ...). The legacy method is only used by deprecated_set_visit_id(). Not introduced by this PR — pre-existing technical debt.
Summary
Fixes 500 errors on REST API (
/wp-json/slimstat/v1/hit) and admin-ajax.php tracking endpoints caused byexit()being called insideAjax::handle()before the REST framework could return a proper response.Root Cause
Ajax::handle()calledexit()at every return point. When the REST API controller calledTracker::slimtrack_ajax()→Ajax::handle(), theexit()terminated PHP beforeWP_REST_Servercould format and send the JSON response, resulting in HTTP 500 errors. The previous workaround usedob_start()/ob_get_clean()output buffering to capture the output, but this was fragile and unreliable.Changes
src/Tracker/Ajax.phpAjax::process()— contains all tracking logic, usesreturninstead ofexit()at every code path (error codes, checksums, success IDs, zero)Ajax::handle()— now a thin wrapper that callsprocess()thenexit($result), preserving admin-ajax.php behaviorsrc/Tracker/Tracker.phpslimtrack_ajax()— callsAjax::process()instead ofAjax::handle(), returns the result to callerssrc/Controllers/Rest/TrackingRestController.phpob_start()/ob_get_clean()workaround replaced with directTracker::slimtrack_ajax()return valuesrc/Providers/RestApiManager.phphandleAdblockTracking()— capturesslimtrack_ajax()return value,echos it, thenexits (adblock bypass must terminate early like admin-ajax)Call Flow After Fix
Ajax::handle()→process()→exit($result)TrackingRestController→Tracker::slimtrack_ajax()→Ajax::process()→returnRestApiManager::handleAdblockTracking()→Tracker::slimtrack_ajax()→Ajax::process()→echo+exitType of change
Checklist
Summary by CodeRabbit
New Features
Bug Fixes
Tests