Skip to content

Premium Analytics: add Stats traffic queries - #49777

Merged
kangzj merged 3 commits into
trunkfrom
split/stats-core-queries
Jun 23, 2026
Merged

Premium Analytics: add Stats traffic queries#49777
kangzj merged 3 commits into
trunkfrom
split/stats-core-queries

Conversation

@kangzj

@kangzj kangzj commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Fixes #

Proposed changes

  • Add split Stats proxy query factories for the core traffic endpoints: site, top posts, referrers, clicks, search terms, file downloads, top authors, locations, country views, and video plays.
  • Add shared Stats query helpers for normalized query keys, report-param conversion, sanitizer dispatch, and API date mapping.
  • Incorporate earlier review feedback by allowlisting report params, normalizing end_date to the API date param, and keeping query files per endpoint instead of a mixed query module.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No. This adds client query wrappers for existing Stats API/proxy endpoints.

Testing instructions

  • Run pnpm --dir projects/packages/premium-analytics test --runInBand.
  • Run pnpm --dir projects/packages/premium-analytics typecheck.
  • Run pnpm --dir projects/packages/premium-analytics build.
  • Browser verification: while logged in, refresh the live WordPress.com Stats dashboard for jetpack.com, inspect dashboard/detail/period-shortcut requests, and confirm summarized requests still send summarize=1. Replay equivalent authenticated requests without summarize=1 and confirm raw responses expose top-level date, period, days, endpoint-specific bucket arrays, and scalar totals where present.

@kangzj kangzj added Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Feature] Stats Data Feature that enables users to track their site's traffic and gain insights on popular content. [Status] Needs Review This PR is ready for review. [Package] Premium Analytics labels Jun 19, 2026
@kangzj kangzj self-assigned this Jun 19, 2026
@jp-launch-control

jp-launch-control Bot commented Jun 19, 2026

Copy link
Copy Markdown

Code Coverage Summary

This PR did not change code coverage!

That could be good or bad, depending on the situation. Everything covered before, and still is? Great! Nothing was covered before? Not so great. 🤷

Full summary · PHP report

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

@kangzj

This comment has been minimized.

This comment has been minimized.

@kangzj kangzj added [Status] Needs Team Review Obsolete. Use Needs Review instead. and removed [Status] Needs Review This PR is ready for review. labels Jun 19, 2026
@kangzj
kangzj force-pushed the split/stats-core-normalizers branch from 35c63e7 to da463c7 Compare June 19, 2026 04:43
@kangzj
kangzj force-pushed the split/stats-core-queries branch from e10f0b5 to 0d72822 Compare June 19, 2026 04:44
@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

This comment has been minimized.

@kangzj
kangzj force-pushed the split/stats-core-queries branch from 734f111 to b192b40 Compare June 19, 2026 05:10
@kangzj

This comment has been minimized.

@kangzj

This comment has been minimized.

@claude

This comment has been minimized.

This comment has been minimized.

@kangzj

kangzj commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Review loop clear for current head b192b402f0. No required checks are reported for this stacked branch. Local validation after the latest stack rebase passed: pnpm --dir projects/packages/premium-analytics test, typecheck, and build.

@kangzj
kangzj requested review from a team June 19, 2026 06:23
@kangzj
kangzj force-pushed the split/stats-core-normalizers branch from e9be8ec to 5d078f0 Compare June 21, 2026 22:18

@chihsuan chihsuan 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.

Nice work. @kangzj I've added a few suggestions and questions.

],
queryFn: async () => {
const response = await fetchStatsProxy( { version, endpoint, params, method, body } );
return statsSanitizers[ sanitizer ]( response, params ) as TData;

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.

Why we need to use as TData here? Is it possible to derive from the sanitizer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 3e0a3196cd. I removed the free TData generic and the as TData cast. statsProxyQuery now returns the sanitizer-map return union, so callers cannot provide an arbitrary result type that disagrees with the selected sanitizer.

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.

We're using reportOnlyKeys as a denylist to delete any invalid params. If there is any changes to ReportParams, it silently leaks into every Stats request.

The existing Woo fetch helpers do the opposite, it explicitly pick the fields they send. Is it possible to use same approach here to ensure we only allow specific params?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed before the latest push and still in place: reportParamsToStatsQueryParams now explicitly picks the allowed Stats params instead of deleting a denylist of Woo/report-only params. I also kept a test covering unknown/report-only params so future ReportParams additions do not silently leak into report Stats requests.

@@ -0,0 +1,152 @@
/**
* External dependencies
*/

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.

Let's remove this redundant comment. 🙂

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 3e0a3196cd.

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.

Any reason that we need to create a new type that alias to StatsProxyParams? Looks like StatsProxyParams is already a type and exported,.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed earlier: the extra alias was removed and callers use the exported StatsProxyParams type directly.

days?: number;
num?: number;
max?: number;
summarize?: number | boolean;
[ key: string ]: unknown;

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.

nit: Can we make StatsQueryParamInput extend from StatsQueryParams? It repeats the same 7-field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partially addressed: the repeated explicit field list is gone and the output still uses the explicit StatsQueryParamFields allowlist. I kept the input type flexible because raw ReportParams can include non-proxy values such as filters objects; narrowing the input to StatsQueryParams makes those allowlist tests fail at compile time instead of proving they are dropped.

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.

Should a missing requested date return empty instead of an arbitrary day? or would falling back to the sole bucket only when days has exactly one entry (empty otherwise) be safer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I did not change this one in #49777. This lives in the normalizer utility code from the already-merged #49776 rather than the new query-factory layer. For this PR I fixed the query-side range ambiguity by sending summarize: 1 for multi-day report ranges; if we still want to tighten missing-date fallback behavior in getStatsBuckets, I would do that as a small follow-up against trunk.


function datePart( value?: string ) {
return value?.split( 'T' )[ 0 ];
}

function daysBetweenInclusive( from: string, to: string ) {

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.

Similar to another PR, can we use date-fns or @jetpack-premium-analytics/datetime methods, instead of creating helpers here?

For example, we can probably use differenceInCalendarDays to replace this method.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed earlier by using the shared getDaysBetweenInclusive helper from utils/interval, which wraps date-fns differenceInCalendarDays with the inclusive +1 behavior and invalid-range guard we need here.

@@ -79,7 +93,7 @@ export function reportParamsToStatsQueryParams(
const date = params.date ?? to;
const startDate = params.start_date ?? from;
const days =
params.days ?? ( startDate && date ? getDaysBetweenInclusive( startDate, date ) : undefined );
params.days ?? ( startDate && date ? daysBetweenInclusive( startDate, date ) : undefined );

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 computes days for the full selected range and and statsReportQuery sends it to the proxy, but it never sets summarize. In getStatsBucket, it then falls through to days[date], ends up showing one day's totals instead of the whole range.

Could statsReportQuery pass summarize: 1 when the range spans more than one day? Or if single-day is the intended behavior

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 3e0a3196cd: statsReportQuery now adds summarize: 1 for multi-day ranges when the caller did not explicitly provide summarize. I added tests for the default multi-day summarized request and for preserving an explicit summarize: false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

While reviewing #49568 (the Top posts widget port) I noticed it resolves the day bucket differently and I think more correctly. If I understand correctly, WPCOM keys the days bucket by the period start date, not the requested reference date. Could this layer adopt the same approach?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. I did not change this in #49777 because it is also in the normalizer utility layer from #49776, not in the query factory diff. The current #49777 fix avoids the multi-day aggregate ambiguity by requesting summarized data for multi-day ranges. I agree the period-start bucket resolution should be handled in a focused follow-up against trunk so week/month raw bucket lookup matches the Top Posts port.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@kangzj
kangzj force-pushed the split/stats-core-normalizers branch from 53177fe to f4fdbf2 Compare June 22, 2026 21:51
Base automatically changed from split/stats-core-normalizers to trunk June 22, 2026 22:26
@kangzj
kangzj force-pushed the split/stats-core-queries branch from b192b40 to 3bf4050 Compare June 22, 2026 23:01
@kangzj
kangzj force-pushed the split/stats-core-queries branch from 3bf4050 to 9d1cf8b Compare June 22, 2026 23:04
@kangzj

kangzj commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

@claude review plz

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kangzj's task in 1m 50s —— View job


Claude PR Review

  • Read changed query/util/processing files
  • Assess reviewer feedback already raised by @chihsuan
  • Provide review feedback

Nice, clean split — the per-endpoint query files are tidy and the shared statsProxyQuery/statsReportQuery factories read well. A few things worth tightening, mostly echoing and extending @chihsuan's points.

1. as TData defeats the type system (stats-query.ts:91)

return statsSanitizers[ sanitizer ]( response, apiParams ) as TData;

TData is a free generic with no link to sanitizer, so this cast is unchecked — a caller can write statsProxyQuery<SomethingWrong>({ sanitizer: 'site' }) and it compiles. The return type can be derived from the sanitizer map instead, e.g.:

type StatsSanitizerReturn< K extends StatsSanitizerKey > = ReturnType< typeof statsSanitizers[ K ] >;

export function statsProxyQuery< K extends StatsSanitizerKey = 'passthrough' >(
	config: StatsQueryConfig & { sanitizer?: K }
): UseQueryOptions< StatsSanitizerReturn< K > > { ... }

Then statsReportQuery can drop its TData default too, since StatsNormalizedReport already flows from the report sanitizers. This makes the as unnecessary and gives callers real types. Fix this →

2. Allowlist for report params is in — but the proxy path still leaks

On the report path, the new statsParamKeys allowlist (replacing the old reportOnlyKeys denylist) is exactly what @chihsuan asked for — reportParamsToStatsQueryParams now explicitly picks the 9 known keys. 👍 That concern looks addressed at current HEAD.

The remaining gap is the direct proxy path: StatsQueryParams = StatsProxyParams & StatsQueryParamFields, and StatsProxyParams is Record<string, …>. So statsSiteQuery(params)statsProxyQuerystatsQueryParamsToApiParams spreads everything through unfiltered. If that's intentional passthrough it's fine, but it means the allowlist guarantee only holds for statsReportQuery. Worth a comment noting that asymmetry, or running proxy params through the same allowlist.

3. days is computed but summarize is never set

This is @chihsuan's sharpest point and I agree it needs a decision. reportParamsToStatsQueryParams computes days for the full range and statsQueryParamsToApiParams keeps start_date + maps end_datedate, but nothing sets summarize. Tracing getStatsBuckets (processing/stats/utils.ts:137):

  • with both start_date and end_date → returns the full range of day buckets (the early days[endDate] single-bucket branch only fires when start_date is absent), so multi-day charts do work, but
  • the request never asks WPCOM to summarize, so there's no aggregated total for the range — consumers wanting a single range total won't get one.

So it's not strictly the "one day's totals" bug, but the intent is ambiguous. Either set summarize: 1 when days > 1 (and the range total is wanted), or document that this factory is per-day-series only. A test asserting the bucket shape for a 2+ day range would pin this down.

4. Bucket keyed by reference date vs period start (processing/stats/utils.ts:147)

Carrying over @chihsuan's second note re #49568: the single-bucket fast path uses days[endDate]. For day period that's correct, but for week/month WPCOM keys the days map by the period start date, so days[endDate] misses and it falls through to the range filter. Aligning the key resolution with the Top-posts port would make this robust across periods. (This lives in code from #49776, not this PR's diff, but it's reachable through these new queries so flagging it.)

Minor

  • stats-query.ts:1-7 — the empty External dependencies / Internal dependencies doc blocks with nothing under the first; @chihsuan already flagged the redundant comment, agree it can go.
  • StatsQueryParamInput (stats-params.ts:24) — per @chihsuan, it could extend StatsQueryParamFields rather than re-listing via Partial<StatsQueryParamFields> — it already does intersect Partial<StatsQueryParamFields>, so the duplication is mostly the [key: string]: unknown index signature; consider whether that index signature is still needed now that the allowlist drives selection.
  • getDaysBetweenInclusive (interval.ts:11) — re @chihsuan's date-fns suggestion: this already delegates to differenceInCalendarDays internally and adds the inclusive +1 plus invalid-range guard, so the helper is a thin, justified wrapper rather than a hand-rolled date diff. I'd keep it.
  • Test coveragestats-queries.test.ts covers the enabled gate and location query-key. Worth adding a case asserting end_datedate mapping survives into the query key for a report query, and the multi-day bucket behavior from Edit and rename the readme for GitHub #3.

Nothing here is blocking correctness for the day-period case; #1 and #3 are the two I'd resolve before merge.
· split/stats-core-queries

@kangzj

kangzj commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@claude review plz

@claude

claude Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kangzj's task in 1m 42s —— View job


Claude PR Review (re-review at 3e0a3196)

  • Read updated query/util/processing files at current HEAD
  • Verify the fixes for prior review points (as TData, allowlist, summarize)
  • Post review feedback

The three items I'd flagged as worth resolving before merge are all addressed. Verifying each against current HEAD:

1. as TData cast — ✅ resolved

stats-query.ts:52 now returns UseQueryOptions< StatsSanitizerData > where StatsSanitizerData = ReturnType<( typeof statsSanitizers )[ StatsSanitizerKey ]> (line 39), and the as TData cast plus the free generic are gone. A caller can no longer assert an arbitrary result type that disagrees with the sanitizer. 👍

One small tradeoff to be aware of (non-blocking): StatsSanitizerData is the union of all sanitizer return types, so statsSiteQuery(...) types its data as that union rather than narrowing to the site shape specifically. It's sound — just less precise than keying the return on K extends StatsSanitizerKey. Fine to leave as-is; worth a follow-up only if consumers end up needing per-endpoint narrowing.

2. Report-param allowlist — ✅ resolved

reportParamsToStatsQueryParams (stats-params.ts:61) explicitly picks statsParamKeys (the 9 known keys) instead of deleting a denylist. The tests are thorough — stats-params.test.ts now asserts Woo-only params (comp, compare_from, filters, date_type), path-only options (geoMode, utmParams, deviceProperty), and arbitrary unknown params are all dropped. Future ReportParams additions can't silently leak into Stats requests. 👍

Minor, carried over: the direct proxy path (statsSiteQuerystatsProxyQuerystatsQueryParamsToApiParams) still spreads StatsQueryParams through unfiltered, since that type intersects StatsProxyParams (Record<string, …>). The allowlist guarantee holds for the report path only. That's a reasonable passthrough design for direct proxy callers — just noting the asymmetry; not a blocker.

3. summarize for multi-day ranges — ✅ resolved

statsReportQuery (stats-query.ts:82-89) now injects summarize: 1 when the caller didn't set it and days > 1, and preserves an explicit value (incl. summarize: false). I traced this through getStatsBuckets (processing/stats/utils.ts:138): with summarize truthy it returns [] and normalizeStatsReportSummary produces the aggregated range total instead — so a multi-day report now yields the whole-range aggregate rather than the single-day fallback. That closes the ambiguity. Tests cover both the default-summarized and explicit-false cases. 👍

Acknowledged follow-ups (not in this diff)

  • Period-start vs reference-date bucket keying (utils.ts:147, days[ endDate ]) — lives in the already-merged Premium Analytics: add Stats traffic normalizers #49776 normalizer layer. Your plan to align it with the Top-posts port in a focused follow-up against trunk is the right call; for day-period (this PR's path) it's correct, and the multi-day case now goes through the summarize branch anyway.

Minor notes

  • The empty External/Internal dependencies doc block @chihsuan flagged on stats-query.ts is gone. ✅
  • getDaysBetweenInclusive (interval.ts:11) correctly delegates to date-fns differenceInCalendarDays with the inclusive +1 and invalid-range guard — good wrapper.
  • Tiny edge in the summarize guard: typeof statsParams.days === 'number' means a stringy days would skip summarize. Callers go through reportParamsToStatsQueryParams which keeps days numeric, so this is fine in practice — just be aware if days is ever set as a string upstream.

Looks good to merge from my side — all the substantive points are handled and the new tests pin the behavior. Nice iteration. 🚀

Note: I reviewed by reading the code at HEAD; I couldn't execute the test suite in this environment (command needed interactive approval), but the test files read correctly and you've confirmed test/typecheck/build pass locally.
· split/stats-core-queries

...statsParams,
...( statsParams.summarize === undefined &&
typeof statsParams.days === 'number' &&
statsParams.days > 1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

statsReportQuery is a report-specific wrapper around statsProxyQuery.

It accepts Woo/report-style date params like from, to, interval, converts them into Stats params like period, start_date, end_date/API date,
computes days, enables the query only when a date range exists, and now defaults multi-day ranges to summarize: 1.

@kangzj
kangzj merged commit 80f748f into trunk Jun 23, 2026
106 checks passed
@kangzj
kangzj deleted the split/stats-core-queries branch June 23, 2026 02:20
@github-actions github-actions Bot removed the [Status] Needs Team Review Obsolete. Use Needs Review instead. label Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement Changes to an existing feature — removing, adding, or changing parts of it [Feature] Stats Data Feature that enables users to track their site's traffic and gain insights on popular content. [Package] Premium Analytics

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants