Skip to content

Stats: Add blog_id to upsell impression and analytics events - #113364

Merged
kangzj merged 11 commits into
trunkfrom
stats/stats-421-add-blog-id-to-upsell-events
Aug 7, 2026
Merged

Stats: Add blog_id to upsell impression and analytics events#113364
kangzj merged 11 commits into
trunkfrom
stats/stats-421-add-blog-id-to-upsell-events

Conversation

@kangzj

@kangzj kangzj commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes STATS-421

Follow-up: STATS-423 (register the affected events in Tracks with owners and descriptions, and run the STATS-421 verification query — blog_id_pct ≥ 95 — after deploy).

Why

Stats upsell impression events were not carrying the blog_id parameter, which prevented them from being tied to specific sites for impact measurement. Without blog_id, it's impossible to measure impression-to-purchase conversion and determine if upsells are reaching the right audience.

Changes

Added blog_id parameter to all stats-related tracking events:

  • Upsell impression events (stats_card_upsell_view)
  • Notice viewed, dismissed, and CTA/support click events (commercial/free site upgrade, do you love stats, GDPR, tier upgrade, free plan purchase success)
  • Upsell page and modal events (stats_upsell_, stats_upsell_modal_)
  • Purchase flow events (checkout redirect, personal/commercial flows, purchase notices, upgrade sliders)
  • Date control events (date picker opened, date range applied, shortcuts) — including gated clicks via the onGatedHandler implementations
  • Date range navigation and chart type selection events
  • Feedback panel, card, and modal events
  • Locations, posts, devices, and UTM module events
  • Promo card and mini-carousel view/click/dismiss events
  • UTM builder and empty-state action events

Mini-carousel and promo-card view events keep the pre-existing site_id key alongside the new blog_id for data continuity. The purchase-page upgrade-source attribution event now waits for a resolved site ID and fires at most once.

Deliberately left alone: calypso_jetpack_module_toggle — its schema is shared with non-stats producers (site-settings) that also omit blog_id; changing only the stats emitters would fragment the event.

Testing

  • Verified all tracking calls now include blog_id parameter
  • Confirmed code follows existing patterns in the stats module
  • All formatting and linting issues resolved

- Add blog_id parameter to stats_card_upsell_view tracking event in StatsCardUpsell
- Add blog_id parameter to commercial site upgrade notice viewed event
- Add blog_id parameter to free site upgrade notice viewed event
- Add blog_id parameter to do you love Jetpack stats notice viewed event
- Add blog_id parameter to all stats date control events (apply button, shortcut clicks, trigger button)
- Add blog_id parameter to stats date range navigation event
- Add blog_id parameter to feedback panel and card tracking events
- Add blog_id parameter to stats locations module menu clicks and country filter changes
- Add blog_id parameter to stats top posts module menu clicks
- Change promo cards to use blog_id instead of site_id for consistency

Fixes STATS-421 by ensuring upsell impression events carry blog_id so they can be properly attributed to sites and measured for impression-to-purchase conversion.
givenSiteId is essential for tracking analytics and should not be optional.
@kangzj
kangzj marked this pull request as ready for review August 6, 2026 22:01
@kangzj kangzj self-assigned this Aug 6, 2026
@kangzj
kangzj requested a review from dognose24 August 6, 2026 22:05
@matticbot matticbot added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 6, 2026
@kangzj
kangzj requested a review from adamwoodnz August 6, 2026 22:05
kangzj added 5 commits August 7, 2026 10:43
…dback modal, mini-carousel, and upsell components

- Add blog_id to stats_devices_module_menu_clicked event
- Add blog_id to stats_utm_dropdown_opened and stats_utm_dropdown_option_selected events
- Add blog_id to feedback modal tracking events (close_form_modal, submit_form)
- Change site_id to blog_id in mini-carousel events for consistency
- Add blog_id to stats_card_upsell_jetpack upgrade tracking events
- Add blog_id to stats_card_update_jetpack_version tracking events

Ensures comprehensive blog_id coverage across all stats analytics tracking calls.
…hase and upsell flows

- Add blog_id to stats_upsell events (submit, expand, learn more, view)
- Add blog_id to stats_upsell_modal events (submit, view)
- Add blog_id to all stats_purchase events (skip, plan switch, personal switch, classification update, checkout)
- Add blog_id to stats_purchase_personal flow skip event
- Add blog_id to stats_purchase_notice upgrade events
- Ensure complete blog_id coverage for all stats module tracking calls

This completes comprehensive blog_id tracking across the entire stats module.
- Add site_id alongside blog_id in mini-carousel and promo-cards for data continuity
- Use non-null siteId prop instead of nullable selectedSiteId in StatsUpsellModal tracking
- Ensures backward compatibility while transitioning to blog_id field

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

Edited after reading STATS-421 — coverage check added, event registration raised, and the super-props claim below corrected.

Checked this against STATS-421 and its follow-up comment.

Coverage against the issue: complete

All three headline events are handled, and so are the eight added in the follow-up comment. Two non-obvious mappings I verified rather than assumed:

  • jetpack_odyssey_stats_date_picker_opened is the trigger_button key in stats-date-control/index.tsx:73.
  • jetpack_odyssey_stats_feedback_action_present_persistent_section is TRACKS_EVENT_DID_PRESENT_FEEDBACK_CARD in feedback/index.tsx:22.

The useEffect dependency change the issue asked for is there too.

Part 2 of the issue is not addressed

STATS-421 asks for the five events to be registered in Tracks, and notes that none of them is, that none has an owner or description, and that jetpack_odyssey_stats_card_upsell_view already feeds four funnels. The issue makes the case that registration is the step that would have caught this defect in the first place.

Nothing in this PR does that. Either fold it in, or split it into a follow-up issue and say so here, so it doesn't get lost when this closes.

Also worth wiring the issue's verification query into the acceptance check: it expects blog_id_pct at or above 95 for the three headline events after deploy.

Missed events

These are in files this PR already touches.

1. promo-cards/index.jsx:112recordTracksEvent( tracksEventName ) with no properties at all. This is the click counterpart to calypso_stats_traffic_mobile_cta_jetpack_view, which the follow-up comment lists at 45.4%. The view events ~40 lines above got blog_id; the click did not, so the impression/click pair stays inconsistent.

2. components/stats-date-control/index.tsx:237 — the gated branch. Non-gated shortcut clicks now send blog_id (line 244), but gated clicks go via onGatedHandler( [ { name: … } ], … ) with no params. Gated users are the upsell audience, so the same event name will keep splitting into with- and without-blog_id rows.

3. stats-period-navigation/index.jsx:310events.forEach( ( event ) => recordTracksEvent( event.name, event.params ) ) in onGatedHandler. this.props.siteId is in scope and used on the next line. Same pattern untouched in stats-chart-tabs/chart-header.jsx:37 and stats-module/all-time-nav.jsx:91.

Adjacent, outside the issue's scope

Not blocking, but the three notices got _viewed only, and every sibling event in the same file was left alone:

File Untouched events
commercial-site-upgrade-notice.tsx *_notice_dismissed (45/47), *_notice_support_button_clicked (56/60), stats_upgrade_clicked {type:'notice-commercial'} (63)
do-you-love-jetpack-stats-notice.tsx *_notice_dismissed (50), calypso_..._upgrade_button_clicked (61), *_support_button_clicked (66), stats_upgrade_clicked {type:'notice-love-stats'} (72)
free-site-upgrade-notice.tsx *_notice_dismissed (47), *_upgrade_button_clicked (59), *_support_button_clicked (64), stats_upgrade_clicked {type:'notice-free-site-upgrade'} (70)

stats_upgrade_clicked is the one I would still pick up here: this PR adds blog_id to it in stats-card-upsell-jetpack.tsx:40 and stats-purchase-notice.jsx:30 but leaves it off in all three notices, so one event name ends up with two property shapes.

blog_id can be null

undefined is safe — recordTracksEvent strips undefined properties (packages/calypso-analytics/src/tracks.ts:270-273). null is not stripped, so those fires record blog_id: null instead of omitting the property.

The follow-up comment argues every Stats route is scoped to exactly one site, which I take as the reason this is acceptable in practice. Flagging it anyway because the acceptance bar is 95%, not 100%, and the issue itself reports 30% of card_upsell_view rows as anonymous — so it is worth knowing whether nulls are landing rather than assuming they aren't.

Nullable sources feeding the new calls:

  • StatsNoticeProps.siteId: number | null → all three _viewed events.
  • stats-purchase-checkout-redirect.tsx:178siteId: number | null, feeding stats_purchase_button_clicked.
  • stats-purchase-single-item.tsx — all five props interfaces (49, 64, 72, 82, 96) declare number | null; four events.
  • stats-purchase-personal.tsx:25number | null.
  • useSelector( getSelectedSiteId ) (returns number | null) in stats-top-posts/nav-tabs.tsx, stats-upsell/index.tsx, mini-carousel, promo-cards.
  • stats-locations.tsx:57 and stats-module-devices.tsx:102 use as number over getSelectedSiteId — the cast silences the type, not the runtime null.
  • stats-date-control/index.tsx:106getSiteId( state, slug ). The one I would look at first, since it resolves by slug rather than selected-site and is null until the site is in state.
  • locations-nav-tabs.tsx types givenSiteId: number, but its only caller (summary/index.jsx:385) is plain JSX so the type is never checked, and the value passed is getSelectedSiteId( state ).

On the shared helper

Worth settling explicitly, because the issue argues both sides: the Suggested fix section says adding it inside trackStatsAnalyticsEvent "would cover the whole Stats surface in one change", while the Scope section says this is per-call-site rather than a helper defect.

The case for the helper (client/my-sites/stats/utils/index.js:82): it covers every trackStatsAnalyticsEvent caller in one place, including the notice events listed above, and removes the prop-drilling this PR adds in feedback/index.tsx and locations-nav-tabs.tsx. It would not cover the raw recordTracksEvent calls, so those still need the manual pass either way.

One data point in favour of the current per-call-site approach: blog_id cannot be leaned on from super props. client/lib/analytics/super-props.js:46 sets it from the selected site and super props are spread after the event properties (tracks.ts:266), so they win when present — but they are frequently absent. Every jetpack_odyssey_* event in the issue is at 0%, and even the Calypso-prefixed calypso_stats_traffic_mobile_cta_jetpack_view is only at 45.4%. So the explicit property is doing real work on both surfaces, not just Odyssey.

kangzj added 4 commits August 7, 2026 14:12
- Add blog_id to promo-cards click tracking
- Add blog_id to GDPR notice dismiss event
- Add blog_id to tier upgrade notice dismiss event
- Add blog_id to free plan purchase success notice upgrade click
- Notice dismiss/CTA/support-click events in commercial, free-site, and
  do-you-love notices (sibling viewed events already carried blog_id)
- Mini-carousel block click/dismiss events via selected site selector
- Purchase page upgrade-source attribution events
- Gated-upsell event forwarding in chart header, period navigation, and
  all-time nav (payloads built by callers never included blog_id)
- Commercial and PWYW upgrade slider change events
- UTM builder open/interaction events
- Empty-state action interaction events
…g_id

Guard the attribution event behind a ref so the siteId dependency added in
the previous commit cannot double-record it while site selection resolves.
@kangzj

kangzj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass — replying point by point. The review snapshot predates the last three commits on the branch, so several items were already picked up; commit hashes below.

Missed events

  1. promo-cards/index.jsx:112 (bare mobile CTA click) — fixed in d2887ef; the click now sends blog_id matching its view counterpart.
  2. stats-date-control gated branch — covered in 110aba6. The gated events flow through onGatedHandler, and the injection was done at the handler side ({ blog_id: siteId, ...event.params } in stats-period-navigation/index.jsx), so both the no-params date-control events and the { stat_type, source } interval-dropdown events pick up blog_id without touching each caller.
  3. The three onGatedHandler implementations (stats-period-navigation, stats-chart-tabs/chart-header.jsx, stats-module/all-time-nav.jsx) — all three fixed in 110aba6.

Adjacent notice events

Also picked up in 110aba6: _dismissed, _upgrade_button_clicked, and _support_button_clicked in all three notices, plus stats_upgrade_clicked in each — so that event now has a single property shape across all five call sites.

Part 2 — event registration

Split into STATS-423 rather than folded in here: registration (owner + description) happens in the Tracks registry, not in this repo, and bundling it would gate the code fix on that process. The follow-up also carries the acceptance check from the issue (blog_id_pct ≥ 95 for the three headline events after deploy).

blog_id: null

Agreed it can land during the brief window before site state resolves. Two things done about it:

  • The purchase-page attribution event now waits for a resolved siteId and fires at most once (6aebd59) — that was the one spot where the nullable dep could also double-fire.
  • Elsewhere the calls stay blog_id: siteId, consistent with the pre-existing sibling events (_viewed notices, use-tracks.tsx). Every affected route is site-scoped, so nulls are confined to resolution races, which the 95% bar absorbs.

One practical note for the verification query: it should count non-null blog_id rather than property presence, so any null rows that do land count as misses instead of false passes.

Shared helper

Settling it explicitly: staying per-call-site. Injecting inside trackStatsAnalyticsEvent would only cover half the surface (the raw recordTracksEvent calls still need the manual pass), would hide the site dependency from call sites, and — per your super-props data point — the explicit property is doing real work on both prefixes anyway. If we later want belt-and-braces, a helper-level fallback (blog_id only when not supplied) could be a small follow-up, but it shouldn't replace the explicit properties.

@adamwoodnz

Copy link
Copy Markdown
Contributor

Checked the four new commits — the gated handlers, the notices, promo-cards, and the purchase-page guard all land as described, and stats_upgrade_clicked now has one property shape across all five call sites. Also agreed on staying per-call-site.

One cheap follow-on that falls directly out of that decision: client/my-sites/stats/AGENTS.md:79 still documents the convention without blog_id.

import { trackStatsAnalyticsEvent } from './utils';
trackStatsAnalyticsEvent( 'stats_module_expanded', { module: 'referrers' } );

With no helper-level injection, that snippet is what new Stats code gets copied from, and it is exactly the shape that produced STATS-421. Since this PR is the thing establishing the convention, it seems worth writing it down where the next person will actually look:

import { trackStatsAnalyticsEvent } from './utils';

const siteId = useSelector( getSelectedSiteId );
trackStatsAnalyticsEvent( 'stats_module_expanded', { module: 'referrers', blog_id: siteId } );

Plus a line in the Analytics section along the lines of: every event fired on a Stats page is site-scoped and must pass blog_id explicitly — trackStatsAnalyticsEvent does not inject it, and super props do not reliably supply it (0% on the jetpack_odyssey_ prefix, 45.4% on calypso_stats_traffic_mobile_cta_jetpack_view).

That gives reviewers something concrete to point at, and it is the one guard against this recurring that does not depend on STATS-423 getting picked up.

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

👍 Code LGTM, but I have not run it.

Left one comment about agent docs.

@kangzj
kangzj merged commit 4a9ec74 into trunk Aug 7, 2026
13 checks passed
@kangzj
kangzj deleted the stats/stats-421-add-blog-id-to-upsell-events branch August 7, 2026 05:41
@github-actions github-actions Bot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Aug 7, 2026
kangzj added a commit that referenced this pull request Aug 7, 2026
stats_pricing_grid_view fires on mount, stats_pricing_grid_paid_cta_clicked
and stats_pricing_grid_free_cta_clicked on the two CTAs — all through
trackStatsAnalyticsEvent (which prefixes jetpack_odyssey_/calypso_ by
surface) and all carrying blog_id, following the pattern from #113364.
The purchase page's skip button already has its own event.
@a8ci18n

a8ci18n commented Aug 7, 2026

Copy link
Copy Markdown

This Pull Request is now available for translation here: https://translate.wordpress.com/deliverables/34682248

Some locales (Hebrew) have been temporarily machine-translated due to translator availability. All other translations are usually ready within a few days. Untranslated and machine-translated strings will be sent for translation next Monday and are expected to be completed by the following Friday.

Hi @kangzj, could you please edit the description of this PR and add a screenshot for our translators? Ideally it'd include this string: Help %(product)s and get these perks:

Thank you in advance!

kangzj added a commit that referenced this pull request Aug 9, 2026
stats_pricing_grid_view fires on mount, stats_pricing_grid_paid_cta_clicked
and stats_pricing_grid_free_cta_clicked on the two CTAs — all through
trackStatsAnalyticsEvent (which prefixes jetpack_odyssey_/calypso_ by
surface) and all carrying blog_id, following the pattern from #113364.
The purchase page's skip button already has its own event.
kangzj added a commit that referenced this pull request Aug 9, 2026
* Stats pricing grid: add view and CTA analytics events

stats_pricing_grid_view fires on mount, stats_pricing_grid_paid_cta_clicked
and stats_pricing_grid_free_cta_clicked on the two CTAs — all through
trackStatsAnalyticsEvent (which prefixes jetpack_odyssey_/calypso_ by
surface) and all carrying blog_id, following the pattern from #113364.
The purchase page's skip button already has its own event.

* Stats pricing grid: address review feedback from #113366

- Eligibility fails closed when the purchases fetch errors: FETCH_FAILED marks
  the store loaded with an empty list, which read as 'no plan' and would have
  shown the grid to a site that holds one. An error now means 'plan state
  unknown' and falls back to the dashboard.
- The price respects getCurrencyObject's symbolPosition, so locales that put
  the currency symbol after the amount (e.g. de-DE EUR) render correctly.
- The Terms of Service line wraps instead of truncating: dropped the jetpack
  component's nowrap/overflow-hidden, which cut off the disclosure in locales
  that run longer than English.
- Fixed the apostrophe in 'sync your site's data' (was U+2018, the opening
  quote) before the string goes out for translation.
- Dropped the redundant 'Included' labels on the three boolean differentiator
  rows so the mobile fallback names the feature instead of showing a bare
  'Included' with nothing identifying the row. Desktop rendering is unchanged
  (the default label is already 'Included', bolded via strong).

* Stats pricing grid: await the dismissal before navigating from the purchase page

The dismissal was fire-and-forget with a fixed 250 ms before navigating, so on
a slow request the gate mounted on the destination route could issue its
notices GET before the POST landed and re-render the grid after 'I will do it
later'. The cache patch couldn't cover this either: the raw notices entry is
absent when the purchase page was reached directly (nothing on it queries
notices) or when the dwell outlived the cache's gcTime, and setQueryData with
an updater returning undefined is a no-op — while seeding a fabricated notices
object would feed the other notice consumers made-up server state.

The dismiss hook now returns the mutation promise (mutateAsync); both skip
handlers await it before navigating, still navigating if the request fails.
The grid's free CTA stays same-route and unawaited, just absorbing the
rejection.

* Stats pricing grid: cap the awaited dismissal so the skip button never stalls

The dismissal mutation retries once after 3s, so awaiting it unbounded could
leave 'I will do it later' visually dead for many seconds on a flaky network.
Race it against a 2s cap: the healthy path (a fast POST) stays fully ordered
before navigation, while past the cap the request continues in flight and
navigation proceeds.

* Stats: self-correct notice dismissals with query invalidation on success

The mutation retries once after 3s, so any capped await on the dismissal
was arithmetically guaranteed to lose to a failed first POST, leaving the
stale pricing_grid state cached for up to 30s. Invalidating the notices
query from the mutation (mutation-level, since per-call callbacks are
skipped once the caller unmounts on navigation) self-corrects whenever
the POST lands, for every notice using this mutation. That makes the
awaited navigation redundant, so the skip handlers return to the plain
dismiss-and-navigate shape and the rejection handling moves into the
dismiss hook.

* Stats purchase: carry the referrer on the skip events

The dismissal now fires regardless of referrer, so without `from` a skip
that came from a module upsell is indistinguishable from one off the
pricing grid.

* Stats pricing grid: cover the eligibility hook with tests

Also widen the getPurchasesError comment: the field is shared across all
purchases actions, not just the site fetch — fail-closed either way.

* Stats pricing grid: record which CTA was clicked
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.

4 participants