Skip to content

Activity Kit Stats: unique downloaders, download rate fix, last downloaded/viewed columns - #3654

Closed
Piyopiyo-Kitsune wants to merge 2 commits into
trunkfrom
feature/activity-kit-stats-downloader-metrics
Closed

Activity Kit Stats: unique downloaders, download rate fix, last downloaded/viewed columns#3654
Piyopiyo-Kitsune wants to merge 2 commits into
trunkfrom
feature/activity-kit-stats-downloader-metrics

Conversation

@Piyopiyo-Kitsune

Copy link
Copy Markdown
Collaborator

What

The Activity Kit Stats dashboard (Activity Kits → Stats) currently shows a "Download Rate" computed as raw total downloads ÷ views. Since a visitor can download the same kit's ZIP more than once, total downloads can exceed views, producing a rate over 100% — not a meaningful "did visitors convert" signal. The dashboard also had no history of when a kit was last downloaded or last viewed, and the "Last Updated" column name was ambiguous.

This PR:

  • Adds unique-downloader tracking via a new custom table (wp_activity_kit_downloads), extending the already-shipped, low-frequency download-tracking endpoint (Activity kits: server-side download tracking #3592) rather than adding new per-page-view tracking.
  • Redefines "Download Rate" as unique downloaders ÷ views × 100, fixing the >100% issue.
  • Adds "Last Downloaded" (postmeta timestamp) and "Last Viewed" (approximated from Jetpack's per-post view history, fetched only on dashboard load) columns.
  • Renames "Last Updated" to "Kit Last Updated" for clarity.
  • Adds hover tooltips to every metric column header explaining what it surfaces, including an explicit note that "Unique Daily Downloaders" dedupes per day, not per selected range.

Why not track unique page visitors too?

Jetpack Stats has no per-post unique-visitor data anywhere in its public API — only site-wide aggregates. A prior PR on this repo (#3496) had a similar "record on every page view" tracking endpoint explicitly rejected by WordPress.org infra (dd32) for caching/scale reasons (writing to the DB on every page view defeats edge caching), not privacy. This PR deliberately doesn't repeat that pattern — no new page-view tracking is added. Downloads are a different story: they're a comparatively rare, already-server-side-tracked event, so extending that with one more low-frequency write (a dedup record per kit/visitor/day) is consistent with the precedent that already shipped, not the one that was rejected.

Privacy design

Visitors are identified by a one-way hash: hash(daily_salt . ip . user_agent . kit_id), where the daily salt is hash_hmac('sha256', $day, $site_secret). No raw IP is ever stored — only the hash. Because the salt is derived per-day from a secret that never leaves the server, the same visitor produces an unrelated hash on a different day, so no persistent per-visitor identifier is ever stored or derivable. This is the same pattern privacy-focused analytics tools (Plausible, Fathom) use to avoid needing cookie consent.

One consequence of this design, called out explicitly in the "Unique Daily Downloaders" column's tooltip: dedup resets daily, so the same person downloading on two different days within a multi-day range is counted twice, not once. This was verified directly in local testing.

Verified locally in wp-env

  • Table auto-creates via the init hook on a fresh, anonymous, unauthenticated request (not gated behind an admin ever loading wp-admin first).
  • The real /wp-json/activity-kits/v1/download/{id} REST endpoint records a unique-downloader row and updates _activity_last_downloaded.
  • Unique-downloader counts dedupe correctly within a day, and correctly do not dedupe across days (matching the disclosed tooltip behavior).
  • Dashboard renders all 8 columns, tooltips, and CSV export correctly.
  • Full WordPress phpcs standard: clean on all touched files (one pre-existing false-positive $wpdb->prepare() placeholder-count warning already present identically in trunk's get_download_counts(), not introduced here). JS lint clean. No stacked // comments.
  • Ran a /code-review pass; all 8 findings addressed (6 fixed, 2 explicitly left unchanged with reasoning documented in code comments — see activity-kit-downloads-db.php and activity-kit-rest.php).

Pre-merge follow-ups (not blockers, flagging for reviewers)

  • Verify the real Jetpack weeks response shape against a live Jetpack connection (staging or Jurassic Tube) — extract_last_viewed_date() was written defensively but the exact day-level shape wasn't confirmed against live data.
  • Get a quick sign-off from whoever owns the WordPress.org privacy policy for this new visitor-hash tracking.
  • Confirm with WordPress.org meta/infra whether production table creation needs a manual provisioning step, per the existing wporg_user_sessions precedent (that table's schema is documented as a comment only, not created via dbDelta() in production).

🤖 Generated with Claude Code

…oaded/viewed columns

Jetpack Stats has no per-post unique-visitor data, and a prior PR (#3496) had a
"record on every page view" endpoint rejected by WordPress.org infra for
caching/scale reasons — so this doesn't add any new page-view tracking.
Instead:

- Adds unique-downloader tracking via a new custom table
  (wp_activity_kit_downloads), extending the already-shipped, low-frequency
  download-tracking endpoint (#3592) rather than repeating the rejected
  per-page-view pattern. Visitors are identified by a one-way hash salted
  with an HMAC-of-the-day secret, so the same visitor's hash can't be
  correlated across two different days and no raw IP is ever stored.
- Redefines "Download Rate" as unique downloaders ÷ views × 100, fixing the
  original problem where repeat downloads by the same person could push the
  raw-download-based rate over 100%.
- Adds "Last Downloaded" (postmeta timestamp) and "Last Viewed" (approximated
  from Jetpack's per-post view history, fetched only on dashboard load) columns.
- Renames "Last Updated" to "Kit Last Updated" for clarity, and adds hover
  tooltips to all metric column headers explaining what each surfaces —
  including an explicit note that "Unique Daily Downloaders" dedupes per day,
  not per selected range.

Schema creation is hooked to `init` rather than `admin_init` so the table
exists before the first public, unauthenticated download request needs it.

Verified locally in wp-env: table auto-creates on a fresh anonymous request,
the real download REST endpoint records rows and updates the last-downloaded
timestamp, unique-downloader counts dedupe correctly within a day, and the
dashboard renders all 8 columns/tooltips/CSV export correctly.

Pre-merge follow-ups (called out for reviewers, not blockers to this PR):
verify the real Jetpack `weeks` response shape against a live connection,
get a privacy-policy sign-off for the new visitor-hash tracking, and confirm
with WordPress.org meta/infra whether production table creation needs manual
provisioning (per the wporg_user_sessions precedent).

Co-Authored-By: Claude Sonnet 5 <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 enhances the Activity Kit Stats dashboard by introducing unique-downloader counting (to make “Download Rate” meaningful), adding “Last Downloaded” / “Last Viewed” dates, and clarifying column naming/tooltips for better interpretation of metrics.

Changes:

  • Add a new custom table-backed unique-downloader tracking mechanism and integrate it into the existing download REST endpoint.
  • Redefine “Download Rate” to use unique downloaders rather than raw download totals, and surface new last-downloaded/last-viewed columns.
  • Update the Stats dashboard UI (table, sorting, CSV export, tooltips) to show the new metrics and renamed column labels.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
wp-content/plugins/wporg-learn/wporg-learn.php Loads the new unique-downloads DB module.
wp-content/plugins/wporg-learn/js/activity-kit-stats/index.js Updates stats UI rendering/sorting and CSV export for the new metrics/columns.
wp-content/plugins/wporg-learn/inc/activity-kit-stats-page.php Adds new table headers, tooltips, and column label updates.
wp-content/plugins/wporg-learn/inc/activity-kit-rest.php Records unique downloads + last-downloaded timestamp; adds last-viewed fetching and new fields to stats payload.
wp-content/plugins/wporg-learn/inc/activity-kit-downloads-db.php Introduces the custom table schema management and unique-downloader counting queries.

💡 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-downloads-db.php Outdated
Comment thread wp-content/plugins/wporg-learn/inc/activity-kit-downloads-db.php
Comment thread wp-content/plugins/wporg-learn/inc/activity-kit-rest.php Outdated
…arse

- maybe_upgrade_schema() now only records DB_VERSION as current after
  confirming the table actually exists (SHOW TABLES), since dbDelta()
  doesn't reliably signal failure. Previously a failed CREATE would still
  mark the upgrade done, permanently skipping retries on that environment.
- get_site_secret() now re-reads the persisted option when add_option()
  loses a concurrent race, instead of trusting the freshly generated (but
  unpersisted) value — avoids two requests hashing against different
  secrets on the first day the secret is created.
- get_last_downloaded_date() now parses the stored GMT timestamp with an
  explicit +0000 offset, so strtotime() can't shift the resulting day near
  midnight on a server whose default timezone isn't UTC.

Verified all three in wp-env via direct eval: simulated an add_option()
race, confirmed the schema guard skips marking version-done when the table
is absent and still succeeds on the real happy path, and confirmed the
date-parse fix produces the correct day regardless of server timezone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@maciejpilarski

Copy link
Copy Markdown
Collaborator

Thanks for this, Destiny. I took it a step further in #3662, which keeps your columns, tooltips and the download-rate change but drops the custom table: uniques go into a second daily postmeta bucket with a transient dedupe, so nothing needs provisioning on wordpress.org. Three things were fixed on the way: the weeks parser was reading WordPress.com's list-of-days shape as a date map (every kit would have shown "Invalid Date"), the schema check would have run dbDelta on every request wherever the table could not be created, and hashing the User-Agent into the visitor key let one client mint unlimited uniques. The full reasoning is in the new PR. Happy to fold anything back if you would rather keep this one.

@Piyopiyo-Kitsune

Copy link
Copy Markdown
Collaborator Author

Thank you for the thorough review and revision @maciejpilarski . Happy to close this PR in favor of your new one #3662 .

@maciejpilarski

Copy link
Copy Markdown
Collaborator

Thanks, Destiny. Your columns, tooltips and the download-rate change are all in #3662 as they were, and you are co-author on the commit. Lint is green there and it is waiting on @obenland. One ask, if you have time before you go: you verified the original in wp-env with a Jetpack connection, which I do not have here. The PR body has a seven-step checklist (double download, the five-a-day cap, a real Last Viewed date, Jetpack off and on, quick range switching, CSV in Firefox and Safari, the tooltips). If you can run through it and note the result on #3662, that would settle the one thing I could not test.

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

Labels

[Type] Enhancement New feature request for the Learn website.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants