Activity kits: server-side download tracking - #3592
Conversation
Jetpack's get_clicks() only captures outbound (external-domain) clicks,
so same-domain ZIP downloads were never counted. This PR replaces the
client-side Jetpack approach with a dedicated REST endpoint that
increments a post meta counter on every click, then 302-redirects to
the actual file.
Changes:
- REST: add GET activity-kits/v1/download/{slug} route (public)
- Looks up kit by slug, resolves ZIP attachment URL
- Increments _activity_download_count post meta before redirect
- Returns 302 WP_REST_Response with Location header
- REST: fix view stats to use get_total_post_views() instead of
get_top_posts() so recently published kits aren't missed
- REST: remove get_jetpack_download_clicks() — now dead code
- REST: read downloads directly from _activity_download_count post meta
- post-meta: register _activity_download_count (integer, default 0,
show_in_rest false)
- Template: point both download buttons at the new /download/{slug}
REST endpoint instead of the direct attachment URL
- Template: remove redundant window._stq JS download tracking block
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR replaces client-side / Jetpack outbound-click tracking for Activity Kit ZIP downloads with a server-side REST redirect endpoint that increments a per-kit download counter in post meta, ensuring same-domain downloads are counted reliably.
Changes:
- Adds a public REST download endpoint that redirects to the ZIP while incrementing
_activity_download_count. - Updates the stats implementation to fetch per-kit views via Jetpack’s
get_total_post_views()and reads downloads directly from post meta. - Updates the single activity kit pattern to link download buttons to the new REST endpoint and removes the old JS tracking block.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| wp-content/themes/pub/wporg-learn-2024/patterns/single-activity-kit-content.php | Points download buttons at the new REST redirect endpoint and removes JS click tracking. |
| wp-content/plugins/wporg-learn/inc/post-meta.php | Registers _activity_download_count post meta used for server-side download tracking. |
| wp-content/plugins/wporg-learn/inc/activity-kit-rest.php | Adds the download redirect route and updates stats logic (views via Jetpack per-ID query; downloads via post meta). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Context on the tracking approachThis PR uses a REST redirect endpoint for download tracking, which is architecturally different from what dd32 flagged as non-viable in #3496 — but similar enough that I want to flag it proactively rather than wait for a review surprise. What was rejected in #3496: A Why Jetpack still doesn't capture downloads: Dion noted in #3496 that "The dedicated File Downloads report is structurally unavailable for this site" because WordPress.org serves uploads from How this PR differs: The REST endpoint here is the download mechanism — both download buttons now point at The open question: This still bootstraps WordPress + REST + a Happy to rework the approach based on feedback before this gets further into review.
|
- Use a compare-and-swap retry loop (update_post_meta $prev_value) to avoid lost increments under concurrent downloads - Sanitize Location header value with esc_url_raw() - Decouple downloads from $jetpack_unavailable gate: views depend on Jetpack but downloads come from post meta regardless - Add auth_callback to _activity_download_count meta registration, consistent with other activity_kit meta keys - Remove unused $zip_url variable from single-activity-kit-content.php Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
obenland
left a comment
There was a problem hiding this comment.
Ran a review on this PR — six inline comments below, plus two findings in files this PR doesn't touch:
1. Library grid/search cards still bypass the counter entirely. wp-content/themes/pub/wporg-learn-2024/inc/block-hooks.php (~line 274) and wp-content/plugins/wporg-learn/views/block-activity-kit-card.php (lines 89–93) still link straight at wp_get_attachment_url() with the now-orphaned data-post-id/data-track-download attributes. This PR deletes the only consumer of those attributes (the _stq click script) and removes the Jetpack clicks aggregation, so every download from the Activity Library grid or search results is counted nowhere — the dashboard silently under-reports. Suggest one get_download_url( $kit_id ) helper in the plugin used by all four link sites, and dropping the dead attributes.
2. The stats page still hard-gates on Jetpack, making download counts unreachable exactly when they'd matter. wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js (~line 402): render() early-returns on the jetpackAvailable flag before fetchStats() is ever called, with a message saying download counts require a Jetpack connection — which this PR makes false. The recorded _activity_download_count values are never displayed when Jetpack is disconnected. Suggest always fetching and showing views as "—" when the response carries jetpack_unavailable (which is currently dead payload no JS reads).
Also worth noting: historical Jetpack-clicks download data is lost with no backfill — the new counter starts at zero for every kit, so the dashboard will show a cliff.
|
|
||
| register_rest_route( | ||
| 'activity-kits/v1', | ||
| '/download/(?P<slug>[a-z0-9-]+)', |
There was a problem hiding this comment.
This route regex is narrower than what WordPress allows in post_name, so some kits get a dead Download button (REST 404 JSON instead of the ZIP) — a regression vs. the old direct link.
sanitize_title_with_dashes() keeps underscores, and non-Latin titles become percent-encoded slugs (utf8_uri_encode()) — relevant here since activity kits are explicitly multilingual and the bulk importer sets post_title with no slug control. A kit slugged trivia_night or %e3%81%82… renders an href that WP_REST_Server never matches, and the user gets rest_no_route. The sanitize_callback => 'sanitize_title' can't help — sanitization runs only after a route matches.
Fix options: widen to (?P<slug>[^/]+) and rawurlencode() the slug when building the URL, or route on post ID instead.
There was a problem hiding this comment.
Fixed — route now uses (?P<id>\d+) (post ID) rather than a slug pattern. The template builds the URL with $kit_id directly, which is always a plain integer and has no encoding ambiguity.
| $retries = 0; | ||
| do { | ||
| $current_count = (int) get_post_meta( $kit_post->ID, '_activity_download_count', true ); | ||
| $updated = update_post_meta( $kit_post->ID, '_activity_download_count', $current_count + 1, $current_count ); |
There was a problem hiding this comment.
This compare-and-swap loop unfortunately doesn't prevent lost increments in either scenario it targets:
$prev_value = 0degrades to a blind write.update_metadata()only adds themeta_valueWHERE clauseif ( ! empty( $prev_value ) ), so the 0→1 increment is unconditional — two concurrent first downloads both write1.- For counts > 0, a genuine CAS failure can't recover. When
$wpdb->updatematches zero rows it returns beforewp_cache_delete, so the loser'sget_post_meta()re-reads the same stale cached value on every retry — all 5 iterations issue the identical failing UPDATE and the increment is silently dropped.
A single atomic query avoids both:
$updated = $wpdb->query( $wpdb->prepare(
"UPDATE {$wpdb->postmeta} SET meta_value = meta_value + 1 WHERE post_id = %d AND meta_key = %s",
$kit_post->ID,
'_activity_download_count'
) );
if ( ! $updated ) {
add_post_meta( $kit_post->ID, '_activity_download_count', 1, true );
}
wp_cache_delete( $kit_post->ID, 'post_meta' );(The comment above the loop would need updating to match, since the concurrency claim it documents doesn't hold.)
There was a problem hiding this comment.
Replaced entirely. The new code issues a single UPDATE … SET meta_value = meta_value + 1 with add_post_meta as a fallback for the first-download case, then calls wp_cache_delete. The UPDATE is atomic at the DB level with no read; the comment above it now documents both failure modes of the old CAS approach (zero-skips-WHERE and stale-cache-on-retry) so the reasoning is clear.
| } | ||
|
|
||
| $result = $stats->get_top_posts( | ||
| $result = $stats->get_total_post_views( |
There was a problem hiding this comment.
get_total_post_views() is being passed the old get_top_posts() range mapping, but the underlying /stats/views/posts endpoint accepts only post_ids/num/date/offset (checked against Jetpack 13.3.1 as pinned in composer.lock, and the WPCOM endpoint docs): period is ignored and num is capped at 30 days. So "90 days" silently returns ~30 days of views, and "All time" (period=month, num=36) also returns ~30 days — wrong by orders of magnitude, with no error. post_ids is additionally capped at 100 IDs, so once the library exceeds 100 kits the overflow IDs silently report 0 views.
Suggest mapping ranges within the num <= 30 constraint (or summing multiple windowed calls) and chunking post_ids.
There was a problem hiding this comment.
Fixed. period is no longer passed. num is capped per window at 30 (7d → 1 call of 7, 30d → 1 call of 30, 90d/all → 3 calls of 30 offset by 0/30/60 days, results summed). post_ids is chunked in groups of 100 and the inner loop issues one call per chunk per window. The rangeLabel() in the JS now shows 'All time (max 90 days)' to be honest about the ceiling.
| return new \WP_Error( 'activity_kit_zip_url', __( 'Could not resolve the download URL.', 'wporg-learn' ), array( 'status' => 500 ) ); | ||
| } | ||
|
|
||
| // Increment the download counter stored in post meta. |
There was a problem hiding this comment.
Style nit: three stacked // lines — per the project comment conventions this should be a single /* */ block with * continuations. (Its content also needs a rewrite per the CAS comment below, since the claim it documents doesn't hold.)
There was a problem hiding this comment.
Rewritten as a single /* */ block. Also took the opportunity to document the two specific failure modes of the old CAS approach that made the comment's original claim incorrect.
- Route on post ID (integer) rather than slug — fixes broken Download buttons for kits with underscores or non-Latin post_name values; updates template to build URL with $kit_id directly - Add User-Agent guard in handle_download() to skip counter increment for known crawlers, social-card unfurlers, and prefetch agents - Add Cache-Control/Pragma no-store headers on the 302 response to make cacheability explicit rather than edge-config-dependent - Replace broken CAS retry loop with atomic UPDATE meta_value + 1 query; add_post_meta as fallback for the initial row; wp_cache_delete after either path. Documented why update_post_meta CAS fails (0 skips WHERE; stale object cache on retry) - Fix get_jetpack_post_views(): remove ignored 'period' param; map ranges to 30-day windows (7d=1 call, 30d=1 call, 90d/all=3 calls); chunk post_ids in batches of 100; sum results across windows and chunks - Update rangeLabel() to 'All time (max 90 days)' to reflect API cap - Suppress Download Rate in table, summary, and CSV export when activeRange !== 'all' (all-time download count vs ranged view count produces a meaningless percentage) - Gate boxRate summary panel visibility on activeRange === 'all' too - Label Downloads column and summary box '(all time)' when range is not 'all' so admins know the scope mismatch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (4)
wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js:403
- Similarly, this overwrites the localized summary label with hardcoded English (“Total Downloads …”). Preserve the existing translated label and only append the suffix when needed.
const dlLabel = summaryDownloads.closest( '.ak-summary-box' )
? summaryDownloads.closest( '.ak-summary-box' ).querySelector( '.ak-stat-label' )
: null;
if ( dlLabel ) {
dlLabel.textContent = activeRange === 'all' ? 'Total Downloads' : 'Total Downloads (all time)';
wp-content/plugins/wporg-learn/inc/activity-kit-rest.php:154
- The atomic increment treats any falsey $updated value as “no row yet”, which (a) hides SQL errors (wpdb::query can return false) and (b) can lose counts on concurrent first downloads: two requests can both see 0 rows updated, one insert succeeds and the other insert fails (unique), resulting in only one increment recorded. Handle false separately, and if the insert fails, fall back to an UPDATE increment.
if ( ! $updated ) {
// No row yet — insert with an initial count of 1.
add_post_meta( $kit_post->ID, '_activity_download_count', 1, true );
}
wp_cache_delete( $kit_post->ID, 'post_meta' );
wp-content/plugins/wporg-learn/inc/activity-kit-rest.php:57
- PR description documents the download endpoint as
/download/{slug}, but the implementation registers/download/{id}and the theme builds URLs using the numeric post ID. Please align the PR description (or switch the route to slug) so API consumers and testers aren’t misled.
register_rest_route(
'activity-kits/v1',
'/download/(?P<id>\d+)',
array(
'methods' => 'GET',
wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js:395
- Setting
thDownloads.textContentto hardcoded English strings overwrites the already-localized header text rendered by PHP (seeinc/activity-kit-stats-page.php:277-279), which regresses i18n for non-English admins. Preserve the existing translated base label and only append the “(all time)” suffix.
This issue also appears on line 399 of the same file.
const arrow = thDownloads.querySelector( '.ak-sort-arrow' );
thDownloads.textContent = activeRange === 'all' ? 'Downloads' : 'Downloads (all time)';
if ( arrow ) {
thDownloads.appendChild( arrow );
}
Fix ESLint/prettier errors flagged by the CI 'Lint JavaScript and Styles' check: superfluous parentheses around boolean conditions in ternary expressions in activity-kit-stats/index.js. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three fixes from the review of this branch: - Route the activity kit card download buttons (Activity Library grid and related-kit cards) through the download endpoint. They still linked straight at the ZIP while the click-tracking script that counted them was removed in this branch, so those downloads would have gone uncounted and the stats page would have undercounted by an unknown margin. This also clears the last data-post-id / data-track-download attributes left over from that script. - Stop returning 404 for suspected bots. The User-Agent heuristic has false positives — a stripped User-Agent, or an in-app browser whose UA names its host app — and those cost a real person the download. Skip the increment and still redirect to the file. - Hide the download button when the ZIP attachment no longer resolves, instead of pointing it at an endpoint that can only answer with a 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
532960f to
7007356
Compare
…arrow kit IDs QA pass on the download tracking implementation surfaced four issues: 1. ! $updated conflates false and 0 (confirmed): $wpdb->query() returns false on a DB error and 0 when no rows matched — ! catches both, causing the INSERT fallback to run even on a real database error. Fix: 0 === $updated so only the no-rows-matched case inserts. 2. UTC-midnight date inconsistency in get_jetpack_post_views() (confirmed): $date was recomputed inside the outer chunk loop. With 101+ kits (two chunks), if a UTC midnight passes between chunks the same window gets anchored to different calendar days for different kits. Pre-compute all window dates once into $dated_windows before the chunk loop. 3. Silent partial-count on API failure (confirmed): a WP_Error on any window continued to the next, returning a plausible-looking undercount as a complete result. Return array() on WP_Error instead — all kits show 0 (visible failure) rather than a silently wrong number. Non-array responses still continue (API returns nothing for a window with no traffic; that is normal). 4. Concurrent first-download race (confirmed): the UPDATE + INSERT fallback is not atomic when the meta row does not yet exist — two concurrent first-downloads can both see 0 rows matched and both INSERT, creating a duplicate meta row. Pre-create the row with count=0 on publish so the UPDATE path is always taken and the fallback never runs on real data. add_post_meta($unique=true) is a no-op on subsequent publish calls, so the counter is never reset. Additionally: split 'all' into 6 view windows (vs '90d' which stays at 3) to give the two ranges meaningfully different data, and narrow $kit_ids to only the requested kit when a slug filter is active so Jetpack is not queried for data that will never be returned. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js:78
- The UI label/comment claims the 'all' range is capped at 90 days, but the updated server implementation of get_jetpack_post_views() sums multiple 30-day windows (currently 6 windows = ~180 days). This makes the UI misleading and can confuse interpretation of stats; align the label/comment with the server behavior (or reduce the server-side windows to match the stated cap).
// 'All time' is capped at 90 days by the WPCOM /stats/views/posts API
// (30-day max per call; see get_jetpack_post_views() in activity-kit-rest.php).
all: 'All time (max 90 days)',
};
return labels[ activeRange ] || 'All time (max 90 days)';
wp-content/plugins/wporg-learn/inc/activity-kit-rest.php:231
- In handle_stats(), this per-kit get_post_meta() call can become an N+1 pattern when many kits are returned. A concrete improvement is to prime the meta cache once for the kit IDs before building $results (e.g., update_meta_cache('post', $kit_ids) or equivalent), then read from cache via get_post_meta without repeated queries.
if ( 'both' === $metric || 'downloads' === $metric ) {
$data['downloads'] = (int) get_post_meta( $kit_post->ID, '_activity_download_count', true );
}
wp-content/themes/pub/wporg-learn-2024/patterns/single-activity-kit-content.php:14
- wp_get_attachment_url( $zip_id ) is called inline solely as an existence check, which duplicates work and makes the condition harder to read. Consider assigning the attachment URL to a variable once (and reusing it if needed) so the condition and intent are clearer.
$kit_id = get_the_ID();
$duration = get_post_meta( $kit_id, '_activity_duration', true );
$zip_id = (int) get_post_meta( $kit_id, '_activity_zip_id', true );
// Link at the counting endpoint, not the file; route on ID since slugs may not URL-encode cleanly.
$download_url = ( $zip_id && wp_get_attachment_url( $zip_id ) ) ? rest_url( 'activity-kits/v1/download/' . $kit_id ) : '';
wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js:394
- Setting thDownloads.textContent replaces all existing header content (including any hidden/screen-reader-only elements or other markup that might be present besides .ak-sort-arrow). A safer approach is to update only a dedicated label node/element (or insert/update a text node while preserving other children) so future accessibility markup in the table header isn’t accidentally removed.
const arrow = thDownloads.querySelector( '.ak-sort-arrow' );
thDownloads.textContent = activeRange === 'all' ? 'Downloads' : 'Downloads (all time)';
if ( arrow ) {
thDownloads.appendChild( arrow );
}
When two simultaneous requests both hit an activity kit that has no _activity_download_count row yet (kits published before the pre-seeding hook was added), the atomic UPDATE returns 0 rows matched for both. Both enter the INSERT fallback; one wins, the other hits add_post_meta() with $unique=true, which returns false (silently a no-op) — that download is lost. Fix: capture the boolean return of add_post_meta(). When it returns false (the concurrent request already inserted the row), re-run the same atomic UPDATE so the increment from this request is still applied. Kits published after the publish_activity_kit hook is in place always have a pre-existing row, so they never reach this branch. Reported by Copilot review comment on PR #3592. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| 'activity_kit', | ||
| '_activity_download_count', | ||
| array( | ||
| 'description' => __( 'Number of times this activity kit ZIP has been downloaded via the tracked download endpoint.', 'wporg-learn' ), |
There was a problem hiding this comment.
| 'description' => __( 'Number of times this activity kit ZIP has been downloaded via the tracked download endpoint.', 'wporg-learn' ), | |
| 'description' => 'Number of times this activity kit ZIP has been downloaded via the tracked download endpoint.', |
I don't think this is user-facing
There was a problem hiding this comment.
Applied in 12284c9 — removed the __() wrapper.
🤖 Written with Claude Code
The 'description' field in register_post_meta() is read only by code (e.g. REST schema introspection), not displayed in the admin UI, so wrapping it in a translation function adds noise without benefit. Per obenland's suggestion on PR #3592. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolved conflicts between trunk (which carries the merged stats-only PR #3591) and this PR (which replaces Jetpack clicks with post-meta download tracking). activity-kit-rest.php: kept our handle_stats() (post meta for downloads, narrowed kit_ids) and our get_jetpack_post_views() (6-window, pre-computed dates). Trunk added get_jetpack_download_clicks() — not needed here since downloads now come from _activity_download_count post meta. index.js: kept our rate-suppression logic for non-'all' ranges (needed: downloads are an all-time counter so rate vs range-scoped views is misleading). Adopted trunk's updated rangeLabel 'All time (max ~6 months)' wording. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…gin) The eslint --fix in the previous merge-conflict commit ran from the repo root (printWidth 120 from the plugin's own .prettierrc), not from the plugin directory where the @wordpress/eslint-plugin/recommended config enforces printWidth 80 via the inline prettier/prettier rule options. Re-ran from the plugin directory to match CI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Problem
get_clicks()in Jetpack Stats only tracks outbound (external-domain) clicks. Since the activity kit ZIP files are hosted onlearn.wordpress.org(same domain), they are never captured — download counts always showed 0.Solution
Replace the Jetpack click approach with a thin REST redirect endpoint that increments a post meta counter before forwarding the browser to the actual file.
This works regardless of file host domain, requires no JS, and persists across page loads.
Changes
inc/activity-kit-rest.phpGET activity-kits/v1/download/{slug}(public, no auth required)_activity_download_countpost metaWP_REST_Response( null, 302, [ 'Location' => $zip_url ] )get_top_posts()toget_total_post_views()so recently published kits with low traffic aren't omitted from results (same fix as Fix activity kit stats: switch to get_total_post_views() for per-kit view counts #3591, included here since this branch is off trunk)get_jetpack_download_clicks()— now dead code_activity_download_countpost meta directly, removing the Jetpack clicks dependency entirelyinc/post-meta.php_activity_download_countmeta (integer, default 0,show_in_rest => false— admin-read-only via REST stats endpoint)patterns/single-activity-kit-content.php/download/{slug}endpointwindow._stqJS tracking block — it was tracking clicks to internal REST URLs (meaningless), and server-side counting replaces it entirelyTesting