Skip to content

Activity kits: server-side download tracking - #3592

Open
Piyopiyo-Kitsune wants to merge 4 commits into
trunkfrom
feature/activity-kit-download-tracking
Open

Activity kits: server-side download tracking#3592
Piyopiyo-Kitsune wants to merge 4 commits into
trunkfrom
feature/activity-kit-download-tracking

Conversation

@Piyopiyo-Kitsune

Copy link
Copy Markdown
Collaborator

Problem

get_clicks() in Jetpack Stats only tracks outbound (external-domain) clicks. Since the activity kit ZIP files are hosted on learn.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.

GET /wp-json/activity-kits/v1/download/{slug}
→ 302 Location: https://learn.wordpress.org/.../activity-kit.zip

This works regardless of file host domain, requires no JS, and persists across page loads.

Changes

inc/activity-kit-rest.php

  • New route GET activity-kits/v1/download/{slug} (public, no auth required)
    • Resolves the kit by slug, fetches the ZIP attachment URL
    • Increments _activity_download_count post meta
    • Returns WP_REST_Response( null, 302, [ 'Location' => $zip_url ] )
  • Fix view stats — switch from get_top_posts() to get_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)
  • Remove get_jetpack_download_clicks() — now dead code
  • Stats endpoint reads downloads from _activity_download_count post meta directly, removing the Jetpack clicks dependency entirely

inc/post-meta.php

  • Register _activity_download_count meta (integer, default 0, show_in_rest => false — admin-read-only via REST stats endpoint)

patterns/single-activity-kit-content.php

  • Both download buttons now point at the /download/{slug} endpoint
  • Removed the window._stq JS tracking block — it was tracking clicks to internal REST URLs (meaningless), and server-side counting replaces it entirely

Testing

  1. Load any published activity kit page
  2. Click Download kit — you should be redirected to the ZIP file
  3. Visit the Stats page — the download count for that kit should increment

🤖 Written with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread wp-content/plugins/wporg-learn/inc/activity-kit-rest.php Outdated
Comment thread wp-content/themes/pub/wporg-learn-2024/patterns/single-activity-kit-content.php Outdated
Comment thread wp-content/plugins/wporg-learn/inc/post-meta.php
Comment thread wp-content/plugins/wporg-learn/inc/activity-kit-rest.php Outdated
Comment thread wp-content/plugins/wporg-learn/inc/activity-kit-rest.php Outdated
@Piyopiyo-Kitsune

Copy link
Copy Markdown
Collaborator Author

Context on the tracking approach

This 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 POST /activity-kits/v1/track endpoint called client-side as a fire-and-forget beacon — an extra REST request layered on top of the real action. Dion's fix was to use Jetpack Stats instead, which #3496 shipped with.

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 /files/ rather than /wp-content/uploads/, so Jetpack's automatic click tracker skips them entirely. The _stq.push() beacon approach that shipped in #3496 has the same limitation — get_clicks() in WPCOM_Stats only returns outbound (external-domain) clicks, so same-domain file downloads never register. That's why the download count has shown 0 since launch.

How this PR differs: The REST endpoint here is the download mechanism — both download buttons now point at /wp-json/activity-kits/v1/download/{slug}, which increments a post meta counter and 302-redirects to the file. There's no extra tracking request; the user can't reach the file without going through the endpoint. The counter is a single integer per kit (not a row-per-event table), so the storage footprint is minimal.

The open question: This still bootstraps WordPress + REST + a update_post_meta write on every download. That's lighter than the original rejected approach, but heavier than a pure Jetpack solution. @dd32 — given that Jetpack's click tracker can't cover /files/ paths, is a REST redirect endpoint like this acceptable on WordPress.org infrastructure, or would you prefer a different mechanism (e.g., a plain PHP handler via rewrite rule that bypasses the REST bootstrap)?

Happy to rework the approach based on feedback before this gets further into review.

🤖 Written with Claude Code

- 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 obenland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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-]+)',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

* @param \WP_REST_Request $request The REST request.
* @return \WP_REST_Response|\WP_Error 302 redirect on success, WP_Error on failure.
*/
function handle_download( $request ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is an unauthenticated GET that performs a state write, with no bot/prefetch/dedup guard — and GET-with-side-effects is exactly what entitles intermediaries to prefetch it. Crawlers, Slack/Discord link unfurlers, email scanners, and browser prefetch/speculation rules all issue plain GETs on the button href, and each one increments the counter, so counts can inflate arbitrarily.

Also, the 302 goes out to anonymous visitors with zero cache headers (WP core only sends nocache headers when is_user_logged_in()). A 302 isn't heuristically cacheable per RFC 9110, but sending nocache_headers() before returning the redirect would make that explicit rather than dependent on edge config. Basic bot filtering/dedup is worth considering too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added two guards: (1) a User-Agent check that 404s known crawlers, unfurlers (Slack, Discord, WhatsApp, Twitter/LinkedIn bots), and empty UAs — they redirect to the file without touching the counter; (2) explicit Cache-Control: no-store + Pragma: no-cache headers on the 302 response so no intermediary can treat it as cacheable.

$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 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This compare-and-swap loop unfortunately doesn't prevent lost increments in either scenario it targets:

  1. $prev_value = 0 degrades to a blind write. update_metadata() only adds the meta_value WHERE clause if ( ! empty( $prev_value ) ), so the 0→1 increment is unconditional — two concurrent first downloads both write 1.
  2. For counts > 0, a genuine CAS failure can't recover. When $wpdb->update matches zero rows it returns before wp_cache_delete, so the loser's get_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.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

}
}
if ( 'both' === $metric || 'downloads' === $metric ) {
$data['downloads'] = (int) get_post_meta( $kit_post->ID, '_activity_download_count', true );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Downloads are now an all-time counter, but the stats UI still range-scopes views and computes downloads / views "Download Rate" percentages per row, in the summary, and in the CSV export (index.js lines ~97–99, 119–121, 295–297, 472–475), stamping the chart with the range label. With "Last 7 days" selected, a kit with 500 lifetime downloads and 20 views this week renders 2500.0%. Only the default "all" range is internally consistent.

Either drop the range buttons, or label downloads as all-time and suppress the rate when range !== 'all'.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in all three spots (summary, table rows, CSV export): the Download Rate is now suppressed to / N/A when activeRange !== 'all', and the rate summary box is hidden in those cases. The Downloads column header and summary box label also update to read Downloads (all time) when a range-scoped view window is active, so admins can see the mismatch at a glance rather than misread the rate.

}

$result = $stats->get_top_posts(
$result = $stats->get_total_post_views(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.textContent to hardcoded English strings overwrites the already-localized header text rendered by PHP (see inc/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>
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.

3 participants