WebProCMS ships with a privacy-first analytics layer built directly into the dashboard — no third-party script tag, no Google Analytics login, cookieless by default. Editors see traffic, goal conversions (including order revenue), source/UTM attribution with revenue, search activity, countries, devices, broken links, and live visitor counts in the same dashboard they edit content in. The data lives in the install's own database; nothing is sent to any external service unless the operator opts into the built-in GA4 relay, and every number can also be pulled over the REST API or shared read-only via a secret link.
The problem
Most CMSes punt on analytics. They tell users to drop in a Google Analytics or Plausible snippet and assume that's good enough. It isn't:
- Cookie banners. Google Analytics needs them in most jurisdictions. The marketing pages designed without them then get ugly retrofits to comply.
- Account sprawl. Editors need a Google login, the right GA property, the right view, and they need to learn an interface that's three clicks away from anything they actually wanted to look at.
- Third-party page weight. GA and similar libraries cost 30–80 KB and one or two extra DNS lookups per pageview, which the install spent a lot of CSS-bundle work to avoid.
- No connection to the editing surface. The "what's working" answer lives in one tool; the "let me edit it" answer lives in a different tool. Editors flip back and forth.
Self-hosted alternatives like Plausible or Matomo solve the first three but require the operator to host an entirely separate service, which most WebProCMS clients don't want to deal with.
The fix
A self-contained feature module under app/Features/Analytics/ records every public pageview server-side via middleware and renders the results across eleven dashboard pages: Overview, Realtime, Pages, Referrers, Sources, Conversions, Search, Countries, Devices, Not Found (404s), and Settings. Tracking is cookieless, IP-less (only salted hashes are stored — daily-rotating for unique visitors, and a separate stable hash for campaign attribution that lets a Monday ad click connect to a Wednesday form fill), and self-hosted. There's no script tag and no third-party network call, with one opt-in exception (the engagement beacon, below).
Like Events, Analytics is structured as a feature module and gated by feature:analytics middleware on every dashboard route. The tracking middleware is registered in the global web group but short-circuits when the feature is off (or when a hit looks like a bot, prefetch, dashboard request, non-HTML response, a Do-Not-Track / Sec-GPC visitor, or a logged-in manager/admin/super staff user — staff browsing their own site never inflates the numbers. Standard-role users still count: they're often customers who happen to have a login. Members signed in on the public members guard are real visitors and still count too). When the feature flips off, the routes 404 and the sidebar entry hides; the data stays in place.
What's tracked, in plain terms
- Pageviews. Every public, GET, 200-OK, HTML response from the site adds 1 to a daily counter for that path + referrer domain. Aggregated, not per-event.
- Unique visitors. By default, a SHA-256 hash of
IP | user-agent | date | app keydedups same-day repeat visits. The hash rotates daily and embeds the app key, so it can't be used as a cross-site identifier or to identify an individual. When the optional persistent visitor cookie is enabled (see below), the cookie value replaces the IP+UA portion of the hash — the daily-rotation property still holds, but the same person on two devices/networks is now correctly counted as one visitor. Optional — operators can turn off unique-visitor tracking entirely in Settings. Two reporting consequences of the daily rotation, reflected in the dashboard labels: the Overview card is Daily Unique Visitors (unique visitors per day, summed across the range — a visitor who returns on 5 days counts 5 times), and the per-page/per-referrer column is Entrances (a visitor's daily unique credit is attributed to the page and referrer of their first hit of the day, so it measures landings, not per-page reach). - Realtime hits. A short-window event log (last 30 minutes, by default) of individual pageviews so the Realtime page can show live activity. Each row also stores the visit's UTM source/medium/campaign so the realtime view can show which campaign is live right now. Pruned every 5 minutes.
- Goal conversions. A single
conversionstable is the goal registry for the whole CMS, tracking seven goal types:form(every successful submission of a form builder form — contact, job application, photo contest, whatever the form's own type — all of which render through the samecontact-formcomponent),order(an Ecommerce order the instant it's marked paid, including subscription renewal invoices — stores the gross value in cents),subscriber(a new Marketing list signup),lead(a Real Estate lead capture — contact request, home valuation, packet download, buyer questionnaire),member_signup(a Memberships account creation — free or paid-plan signup, recorded once at account creation rather than at a paid plan's later Stripe activation),booking(an Online Booking appointment reaching confirmed status — stores the paid amount in cents, or nothing for a free service), anddonation(a Donations gift the instant it's marked paid, including recurring renewals — stores the gross value in cents). Each row stores a module-ownedreference_id(form id, order id, listing id, …) with no database foreign key, so a conversion outlives its referent — a deleted form's past conversions still count, shown as "(deleted form)" in the Conversions dashboard instead of disappearing. Independent of a form's own "save submissions" toggle, so analytics works even when a form is set to email-only. Not counted: spam-quarantined and honeypot-tripped form submissions, and test submissions from the page-editor preview or by logged-in manager-or-above staff (Standard-role users' submissions count — they're often customers). Do-Not-Track visitors' conversions are counted but stored with no visitor hash (and therefore no campaign attribution). - Search queries. Recorded whenever a visitor submits a results page: the site search (
/search, sourcesite), the Real Estate search page (sourcereal_estate), and any page embedding a shop products collection (sourceshop, via the products data-source's search hook, so a runtime copy of the shop page still counts). Each record stores the source, the term, and how many results came back. Short prefixes (< 3 chars) and rapid-fire repeats of the same term within 10 seconds are filtered out automatically. Do-Not-Track/GPC visitors' searches are still recorded — a search submission is an explicit action, so it's counted, but the visitor's IP is never used, even for the 10-second dedupe key. (The command-palette live search deliberately doesn't record — keystroke-driven queries are noise; the results-page submit is the "search performed" event.) - Campaign attribution. Three views ship out of the box:
- Per-visit (industry standard). Every tracked pageview increments a row in
page_view_campaignskeyed by the visit's(utm_source, utm_medium, utm_campaign)— empty strings for untagged visits. The Per-visit view groups this table directly, so "Direct / None" appears as a real row alongside every UTM-tagged campaign — the same pattern Plausible, Fathom, Simple Analytics, and Umami use to handle the "what about untagged traffic?" question. - First-touch / Last-touch. When a visitor lands with any
utm_source/utm_medium/utm_campaign/utm_term/utm_contentparameter or an ad-platform click ID (gclid/fbclid/msclkid/ttclid/li_fat_id), one row gets upserted intovisitor_attributionskeyed by the visitor's stable hash — first-touch fields lock on insert, last-touch fields update every visit. The First-touch / Last-touch views group this table and show only UTM-tagged campaigns (untagged visitors don't get an attribution row at all). - Conversion attribution. The moment any goal is recorded (not just forms), the visitor's stable hash is used to look up
visitor_attributions, and both first-touch and last-touch UTMs are denormalized onto theconversionsrow so the Sources dashboard can show conversions per campaign without a join. The Per-visit view also surfaces "Direct / None" conversions (visitors who converted without ever carrying a UTM).
- Per-visit (industry standard). Every tracked pageview increments a row in
- Revenue per source (Ecommerce installs). When the Ecommerce feature is on, the Sources dashboard adds Orders and Revenue columns to all three attribution modes (and their CSV exports) — see "Revenue per source" below.
- Countries. When the site sits behind a CDN that sends a country header, each pageview's two-letter country code rolls up into a daily counter — see "Countries" in Dashboard surfaces below.
- Devices & browsers. Every pageview classifies the request's user agent into a coarse device bucket and browser family and rolls it into a daily counter. The raw user-agent string is never stored.
- 404s (Not Found). A real 404 that passes the same human-traffic guards as pageviews rolls up into a daily counter keyed by the missing path and referrer domain, with a link straight to the Redirects manager.
- Engagement (opt-in, off by default). Time-on-page, scroll depth, and bounce require a small first-party script — the one thing this feature can't measure purely server-side. See "Engagement beacon" below.
What's not tracked: raw IP addresses, raw user-agent strings, cookie identifiers (unless the persistent-cookie opt-in is switched on), session IDs, browser fingerprints, precise geolocation, installed fonts/plugins, or click events beyond the goal conversions above. Country and device/browser are tracked, but only as coarse, bounded buckets: country is a two-letter code read from a CDN header — never a purchased GeoIP database, never city/region/lat-long — and device/browser is one of 3 device buckets × 7 browser families classified from the user agent, with the raw user-agent string discarded immediately after classification. Time-on-page, scroll depth, and bounce are only ever measured when the operator opts into the engagement beacon (off by default), and even then the result is a daily per-path aggregate, never a per-visitor row. Two distinct salted hashes exist: the daily-rotating one (page_view_visitors) cannot be linked across days; the stable one (visitor_attributions, conversions) survives across days for the same IP | user-agent pair so first-touch attribution works for a return visitor. Both are salted by the app key and are pseudonymous — not fingerprints, not cross-site identifiers.
Whose visits count
Every pageview and goal conversion is subject to the same exclusion rules, applied at ingest time (skipped visits are never written, not just hidden from reports):
| Visitor | Counted? | Why |
|---|---|---|
| Anonymous public visitor | Yes | The audience the reports exist for. |
Signed-in member (Memberships members guard) |
Yes | Members are real visitors — a separate guard from CMS staff. |
| Signed-in Standard-role CMS user | Yes | Standard users are often customers who happen to have a login; their visits are genuine traffic. |
| Signed-in Manager / Admin / Super staff | No | Site operators browsing their own site would inflate the numbers. Uses their real role, so a Super previewing the site as a lower role is still excluded. |
| Visitor from an operator-excluded IP or CIDR range | No (traffic); goal conversions still recorded | The Settings page's excluded-IP list (with a one-click "Block my IP" button) drops all traffic tracking for listed addresses and ranges. Conversions mirror real business events (an order exists whoever placed it), so they still record. |
Browser carrying the opt-out cookie (wpcms_notrack) |
No (traffic); goal conversions still recorded | "Never track this browser" on the Settings page sets a long-lived '1'-flag cookie that survives IP rotation. Same scope as the IP exclusion. |
| Visit to an operator-excluded path | No | The Settings page's excluded-pages list (exact paths or * wildcards) drops the pageview pipeline, funnel path-steps, the 404 rollup, and engagement for matching paths. |
| Page-editor preview iframe | No (form conversions) | An editor testing a form must not record a conversion. |
| Do-Not-Track / Sec-GPC signal | No (pageviews); goal conversions recorded without a visitor hash | The browser asked not to be tracked; a goal conversion is an explicit action, so it's still counted but left unattributed. |
| Bot / crawler / prefetch / non-HTML / non-200 | No | Not human audience traffic. |
The same rules apply to the opt-in engagement beacon (manager+ staff, excluded IPs, and DNT/GPC visitors are excluded there too — see below) and to the pageview beacon described under Edge-cached pages.
The staff exclusion is a hard cut at Manager and above (Role::Manager = level 2; Standard is level 1). One caveat: it depends on an active dashboard session — a staff member browsing the public site while logged out (private window, phone, after logout) is indistinguishable from a normal visitor and will count. That gap is what the Settings → Tracking exclusions card closes, with three mechanisms:
- Excluded IPs — a manual list of IPv4/IPv6 addresses or CIDR ranges (
203.0.113.0/24for an office network; entries validated on save, IPv6 spellings canonicalized, CIDR host bits masked to the network address), plus a one-click Block my IP button that takes effect immediately, no Save needed. On IPv6 the button blocks the whole /64 network rather than the exact address — residential IPv6 privacy extensions rotate the device address inside the delegated prefix every day or so, and an exact entry would silently stop matching. - Never track this browser — a switch that sets a long-lived (400-day) first-party cookie (
wpcms_notrack, literal value'1', no identifier — itself not a tracking cookie) that excludes the browser from all tracking across IP changes. The durable option for home and mobile connections. (TrackingOptOut) - Excluded pages — a list of paths that never track, exact (
/landing-test) or wildcard (/beta/*;*matches any characters including slashes). Applies to the pageview pipeline, funnel path-steps, engagement, and the 404 rollup — a pattern like/wp-*doubles as a mute button for bot-probe 404 noise. (ExcludedPaths)
Excluded IPs and opted-out browsers skip every traffic ingest path — everything TrackPageView writes (page views, uniques, realtime, attributions, funnel hits, 404s), the engagement beacon, on-site search recording, form-funnel beacons, and A/B experiment hits (ExcludedIps). Goal conversions are the deliberate exception — they mirror real business events (an order exists whoever placed it) and still record.
Cost: 4–10 queries per public pageview, deferred
Tracking writes happen inside a defer() callback that runs after the response has been sent to the user. Pageview latency is unaffected — the user already has the HTML by the time analytics writes anything.
Per public hit, deferred:
- 1 query — UPSERT into
page_views(INSERT … ON DUPLICATE KEY / ON CONFLICT UPDATE views = views + 1). - 1 query — UPSERT into
page_view_campaigns(same pattern, keyed by(date, utm_source, utm_medium, utm_campaign)— empty strings for untagged hits). - 1 query — UPSERT into
page_view_devices(same pattern, keyed by(date, device, browser)— coarse buckets classified server-side from the user agent; the raw UA string is never stored). - 0–1 query — UPSERT into
page_view_countries(skipped entirely when no CDN country header —CF-IPCountry,CloudFront-Viewer-Country, orX-Vercel-IP-Country— is present, i.e. on installs not behind a CDN). - 0–3 queries — visitor dedup logic (skipped entirely when the operator has turned off "track unique visitors"). When on: an
INSERT IGNOREintopage_view_visitors; if the row was new (~once per visitor per day), two extraUPDATEs bumpunique_visitorson the matchingpage_viewsandpage_view_campaignsrows. - 0–1 query — realtime event row (skipped when "realtime" is off in Settings).
- 0–1 query — attribution UPSERT into
visitor_attributions(skipped entirely when the request has no UTM or click ID — the common case for direct/organic traffic). - 0–1 query — funnel-step hit
INSERT IGNOREintofunnel_hits(only when the path matches a defined funnel step; the pattern match itself runs deferred against a briefly-cached pattern list, so an install with no funnels pays one cache read, no query).
Worst case: 10 queries deferred per pageview (first hit of the day, all toggles on, UTM-tagged inbound, behind a CDN that sends a country header, path matching a funnel step). Steady state for a returning UTM-tagged visitor: 5–6 queries deferred. Untagged returning visitor: 4–5 queries.
For dashboard, sitemap, robots, prefetch, AJAX/JSON, non-HTML, and bot requests, the middleware short-circuits on the request path before any work runs — 0 queries. A real 404 (GET, HTML, and past the same bot/staff/DNT exclusions) defers exactly 1 query — an UPSERT into the not_found_hits rollup; other non-200 responses record nothing.
Edge-cached pages report from the browser
Pages served straight from an edge cache never run PHP at all (see Edge caching), so no middleware exists to count them. Those pages therefore report their own views: the page posts its path and referrer to _pv/hit, and the endpoint replays the view through the same recording pipeline — same rollups, same unique-visitor dedup, same UTM attribution, same funnel steps. Every exclusion in the table above still applies, evaluated against the visitor's own request: Do-Not-Track and Sec-GPC, excluded IPs and CIDR ranges, the "never track this browser" cookie, excluded paths, and bot user agents. Manager+ staff are excluded via the same signal that hides the edit bar, since a page served without a session can't resolve a dashboard login.
Which channel a page uses is decided by the page, not by a setting: a page that qualifies for static serving is counted by the beacon and not by the middleware, so a view is never recorded twice — including on the cache misses the edge passes through to PHP.
One deliberate consequence: counts for those pages are human-only. Bots don't execute JavaScript, so crawler traffic that the middleware's user-agent list would previously have caught (and excluded) now never reports at all. Pages that keep a session — anything with a form, a login, or a live widget — are still counted server-side exactly as before. If you compare a static page's traffic to a form page's, that difference in method is worth remembering.
The beacon costs the same deferred queries as a middleware-tracked pageview (the list above); it adds one small request per pageload on those pages, sent with sendBeacon so it never delays the page. It is rate-limited per IP and always answers 204.
Settings
Dashboard → Analytics → Settings (admin only) exposes the controls below, all backed by Setting::get/Setting::set:
| Setting | Default | What it does |
|---|---|---|
analytics.track_unique_visitors |
true |
Off skips the visitor dedup writes entirely. The Unique Visitors columns/cards show 0. Privacy-maximalist operators or very high-traffic sites looking to reduce the per-pageview write cost can disable it. |
analytics.realtime_enabled |
true |
Off skips the realtime_hits write. The Realtime dashboard becomes empty. |
analytics.persistent_cookie_enabled |
false |
Opt-in. Sets a 90-day wpcms_vid cookie so a returning visitor counts as the same person across days. Physically disabled in the UI until Cookie Consent is enabled; server-side save handler coerces it back to false if Cookie Consent is off. See "Optional persistent visitor cookie" below. |
analytics.engagement_beacon_enabled |
false |
Opt-in. Adds a tiny (~1.5 KB) first-party script tag to every public page — the only analytics feature that does. See "Engagement beacon" below. |
analytics.weekly_digest_enabled |
true |
One weekly email to admins and supers summarizing the last complete Mon–Sun week. See "Weekly email digest" below. |
analytics.ga_measurement_id |
(empty) | When set to a valid GA4 ID (G-XXXXXXXX), the public layout auto-injects the standard gtag.js bootstrap into <head> on every page. See "Built-in Google Analytics 4" below. |
analytics.client_report_enabled |
false |
Opt-in. Emails a branded PDF traffic summary to a configurable recipient list once per period. See "White-label client report" below. |
analytics.client_report_frequency |
monthly |
weekly (last complete Mon–Sun week) or monthly (last complete calendar month). |
analytics.client_report_recipients |
(empty) | Comma/space/semicolon-separated recipient emails. Every entry is validated on save; enabling the report requires at least one. |
analytics.excluded_ips |
(empty) | Comma-separated list of IPv4/IPv6 addresses or CIDR ranges whose visits are never tracked (all traffic ingest paths; conversions still record). Edited as a one-per-line textarea with per-entry validation, plus a one-click "Block my IP" button that adds the admin's current address (IPv6: the /64 network) and persists immediately. |
analytics.excluded_paths |
(empty) | Newline-separated list of paths that never track — exact or * wildcards. Covers pageviews, funnel path-steps, engagement, and the 404 rollup. Lenient normalization on save (pasted URLs reduce to their path, missing leading slash added). |
(cookie) wpcms_notrack |
(unset) | Not a Setting — the per-browser opt-out flag set by the "Never track this browser" switch. Plaintext '1', 400-day lifetime, in the cookie-encryption except list; honored by every analytics ingest guard. |
analytics.anomaly_alerts_enabled |
false |
Opt-in. Emails admins when a day's views or conversions deviate sharply from the same-weekday 4-week baseline. See "Traffic anomaly alerts" below. |
analytics.anomaly_threshold |
50 |
Percent deviation (10–500) required before an anomaly alert fires. |
analytics.retention_days |
365 |
Daily prune deletes rows older than this many days from every rollup/log table: page_views, page_view_campaigns, page_view_countries, page_view_devices, not_found_hits, page_engagement, search_queries, conversions, funnel_hits, and visitor_attributions (the last aged out against last_seen_at). Two tables prune on their own fixed schedule instead of this setting: page_view_visitors (the same-day dedup ring) every 7 days, and realtime_hits down to the last 30 minutes every 5 minutes. Set 0 to keep the retention-governed tables forever (privacy posture: the operator should explain why). |
analytics.shared_stats_enabled |
false |
Opt-in. Publishes a tokened, read-only traffic page at /stats/{token} — no login required. Saves immediately (not on the page's Save button), so the link exists — or stops working — the moment the switch flips. See "Shared public stats page" below. |
The Settings page also shows live row counts across the analytics tables and offers a one-click "Clear all analytics data" button (admin-confirmed) that truncates every data table — page views, visitors, campaigns, realtime, searches, conversions, attributions, engagement, countries, devices, 404 hits, and collected funnel evidence (funnel_hits). Funnel definitions are operator config and survive the wipe.
Optional persistent visitor cookie (opt-in)
By default the analytics path is fully cookieless — visitor uniqueness is computed from a salted sha256(IP | user-agent | date | app.key) hash that rotates every 24 hours, so a return visitor on day 2 counts as a fresh visitor. This is the "cookieless tax" the whole privacy-first design accepts in exchange for not needing a consent banner.
Operators who already run a Cookie Consent banner (for GA, Meta Pixel, etc.) can flip on Settings → Analytics → Tracking → Use persistent visitor cookie to recover that accuracy. When the toggle is on:
- The middleware sets a 90-day first-party cookie named
wpcms_vidcontaining a random UUID. The cookie ishttpOnly,SameSite=Lax, andSecureon HTTPS — no JS can read it, and it's never shared cross-site. VisitorIdentityprefers the cookie value over IP+UA when computing both the daily-rotating and stable visitor hashes. The cookie value is always salted into asha256withapp.key, so the raw UUID is never the visitor identifier on its own.- Same-visitor return visits on day 2, day 7, day 30 all resolve to the same daily hash + same stable hash. Unique-visitor counts and attribution windows become accurate instead of approximate.
Hard guardrail: requires Cookie Consent. The Settings toggle is physically disabled (greyed out, switch un-flippable) when the Cookie Consent feature is off. The reason is legal: the cookie only has a coherent legal basis when the visitor accepts the consent banner. With Cookie Consent on, the middleware reads wpcms_consent on every request and only issues wpcms_vid after the visitor has granted the analytics category. Visitors who reject continue to be counted cookielessly (no wpcms_vid is ever set for them). The guardrail is also enforced at runtime, not just at save time: the middleware re-checks that Cookie Consent is enabled before issuing the cookie, so an operator who later turns Cookie Consent off automatically stops all wpcms_vid issuance even if the analytics toggle was left on.
The toggle is OFF by default. Most installs don't need it — the cookieless tax is small (10–30% over-count on uniques depending on site stickiness), and the legal risk of getting consent wrong is much larger than the accuracy gain.
Engagement beacon (opt-in)
Server-side tracking can count pageviews but can't see what happens after the page loads — how long a visitor actually reads, how far they scroll, whether they bounce. Measuring those requires JavaScript in the browser. The engagement beacon is the honest trade-off for operators who want that data: flipping Settings → Analytics → Tracking → Engagement beacon adds a tiny (~1.5 KB) inline first-party script to every public page — the one and only analytics feature that puts a script tag on the site. Default installs stay byte-identical: toggle off means no script, no endpoint traffic, nothing.
What the script does: accumulates engaged time only while the tab is visible (capped at 30 minutes), records max scroll depth (rounded to the nearest 5%), and keeps a pages-this-session counter in sessionStorage (bounce = it was the only page). It sends a single navigator.sendBeacon POST to /_analytics/engage as the visitor leaves (on pagehide, with a visibilitychange-to-hidden fallback for mobile tab kills). No cookies are set, and the script bails immediately on Do-Not-Track or Global Privacy Control before it ever measures anything.
Server-side, /_analytics/engage mirrors TrackPageView's guardrails so a direct POST can't bypass what the script already enforces: it independently drops DNT/GPC-flagged requests and manager+ staff visits, rate-limits to 60 requests per minute per IP, caps the request body at 512 bytes, and validates every field (path must start with /, engaged seconds 0–1800, scroll 0–100, bounce a strict 0 or 1) before writing anything. Valid hits roll into page_engagement — one row per (date, path) holding a sample count, a running sum of engaged seconds, a bounce count, and a running sum of scroll-depth percentage points — so the average engaged time and bounce rate for a path are simple divisions, and no per-visitor row is ever kept. The same "(other)" cardinality-cap pattern used for referrers and UTMs applies here too: once a day crosses 5,000 distinct paths, further new paths fold into a shared bucket instead of growing unbounded.
When Cookie Consent is enabled, the script is consent-gated behind the Analytics category — the same shim the GA4 snippet uses — so it stays inert until the visitor grants consent.
The one visible dashboard change when the beacon has data: the Pages report gains an Avg. time and Bounce % column (and matching sort options and CSV columns) — but only for date ranges where the beacon actually recorded something, so a beacon-off install renders the exact same Pages table it always has.
Revenue per source (Ecommerce installs)
When the Ecommerce feature is enabled, the Sources dashboard adds Orders and Revenue columns (and matching summary cards) to all three attribution modes, plus their CSV exports:
- Per-visit mode groups placed orders by the order's own stamped UTM triple —
ShopAttribution's snapshot of the purchase session — so revenue lands in the same "Direct / None" or campaign row the order's own visit would. - First-touch / Last-touch modes join placed orders to
visitor_attributionsby visitor hash and group by the same first/last UTM triple the rest of that mode's columns use, so a visitor's revenue always lands in the same row their visits and conversions do (rather than the order's own possibly-different UTM capture). - Every mode counts an order as "placed" the same way the Shop Reports page does —
paid_atnot null, windowed bypaid_at— and reports revenue net of refunds (total_cents − amount_refunded_cents).
On installs without Ecommerce enabled, the Orders/Revenue columns simply don't render — the Sources page looks exactly like it did before this feature shipped.
Named date ranges
Every report page except Realtime (which is designed to be watched live, not windowed) shares one date-range picker: rolling 7 / 30 / 90 days, calendar This month / Last month / Year to date, and a custom From / To picker. The default is 30 days. Custom and calendar windows are clamped to 731 days (two years) and can never extend past today. Every report page's CSV download button exports exactly the rows the current range and filters show.
Period-over-period deltas (currently the Overview page's Page Views card) compare complete days: when the active window ends today, both the current and prior comparison windows shift back one day so a partial "today" never reads as a dip, while the headline total still includes today's traffic. Calendar or custom windows that already ended in the past compare as-is.
Weekly email digest
On by default, toggleable in Settings. Every admin and super user gets one email per complete Monday–Sunday week, sent the first time traffic is processed after the week closes: total views with the delta vs. the prior week, daily unique visitors, the top 5 pages and top 3 referrers, goal conversions broken down by type (plus order revenue when Ecommerce is on), the top 3 UTM sources by last-touch conversions, total searches and the top search term, and total 404 hits with the single worst-hit missing path.
When the Google Search Console connection is set up (from Analytics → Search or the SEO group's Search Console page — one shared connection serves both), the digest also includes the week's top 5 Google search terms (clicks, impressions, and average position) — the queries people typed into Google before clicking through to the site. Google strips the query from the referrer, so Search Console is the only source for this; the section simply doesn't appear on installs without a connection, and a Search Console API failure never blocks the digest from sending. Search Console is a paid SEO Pro capability, so the section also drops out when the seo_scanner addon is off — even if a connection lingers from before a lapse. Note that Search Console data lags real time by about two days, so a digest sent on Monday may not yet count the final day or two of its week.
Driven by analytics:weekly-digest, which LazyCron fires daily — the command itself gates actual sends to once per week via an analytics.digest_last_sent_week marker, so running it more than once a day (or catching up after downtime) never double-sends. A week with zero page views sends nothing at all — no email is better than an email full of zeros.
Multi-step funnels
Dashboard → Analytics → Funnels answers "where do visitors drop off on the way to converting?" A funnel is an ordered list of 2–8 steps (up to 20 funnels per install), each either a page path (/pricing exact, or /blog/* for the whole section) or a goal from the conversions registry (any of the goal types the Conversions page tracks, offered only when the owning feature is enabled). The page renders each funnel as a horizontal bar chart: visitors per step, percent of the first step, and the drop-off between consecutive steps — windowed by the shared date-range picker.
How the data works, and the one honest caveat:
- Goal steps need no new storage — the conversions registry already stores a stable per-visitor hash, so a goal step works retroactively for any date range.
- Path steps are recorded going forward. The pageview rollups deliberately store no per-visitor paths, so when a funnel defines a path step, the tracking middleware starts recording one
funnel_hitsrow per visitor per step per day (deferred, deduped by a composite primary key, matched against a briefly-cached pattern list so the definition tables are never queried per hit). A range that reaches back before the funnel was created shows a "page-step data collected since …" note so a low first-step count isn't misread as a traffic collapse. - Ordering is enforced at day granularity. A visitor counts for step N when their first hit of step N is on or after the day they qualified for step N−1; same-day completion counts as in-order. A visitor who only ever hit a later step before an earlier one doesn't count for the later step.
Both step kinds share the same stable salted visitor hash (the attribution hash), so a Monday pricing-page visit connects to a Wednesday form fill. DNT/GPC visitors and manager+ staff are excluded exactly as they are for pageviews. Editing a funnel's steps resets its collected path-step evidence (rows are keyed by step position, so old rows would mean something else); deleting a funnel deletes its evidence; goal conversions are never touched by either. funnel_hits respects the retention window and is truncated by "Clear all analytics data" — funnel definitions survive both.
White-label client report
Settings → Analytics → Client report emails a branded PDF traffic summary to a configurable recipient list — the site owner's client, not the CMS admins (the weekly digest already covers those). Off by default. Frequency is weekly (last complete Mon–Sun week) or monthly (last complete calendar month, the default); LazyCron fires analytics:client-report daily and an analytics.client_report_last_sent_period marker keeps it to one send per period. Zero-traffic periods and empty recipient lists send nothing and leave the marker untouched, mirroring the digest.
The PDF (dompdf, letter-size) carries: headline cards (views with delta vs. the prior period, daily unique visitors, conversions, on-site searches), a views-by-day bar chart, top 10 pages, top 5 referrers, conversions by goal with value, top 5 converting sources (last-touch), and top 5 search terms. The email body holds the headline numbers with the PDF attached.
Maintenance sections (2026-07). Beyond analytics, ClientReport::compute() appends a maintenance half — every section renders only when its data exists, and each collector is failure-isolated (rescue → section omitted), so the report degrades gracefully on any install:
- Site care — software version with a period-over-period update line (installs keep no update history, so each scheduled send snapshots the version into
analytics.client_report_version_markerand the next report diffs against it; test sends don't move the marker), backups taken this period + most recent (bounded by local snapshot retention), each off-site destination's last successful delivery (frombackups.offsite_state), and the install-health verdict (InstallHealth::issuesFor— "All checks passing" or the issue list). - Site quality — SEO score with delta vs. the last scan completed before the period plus open/resolved/new issue counts (gated on the
seo_scannerfeature), and the accessibility score with the same baseline treatment (gated onaccessibility). - Security — failed sign-ins, attack lockouts (
login_attack_detected), and successful sign-ins from the activity log'sauthcategory (gated on theactivity_logfeature +log_authtoggle). - Site activity — pages edited (distinct
page_override_versions.page_slugin the period — the editor's save trail), content items added/updated; hidden when all zero. - Work performed — manual, dated entries from the Work log manager on the settings card (
analytics.client_report_work_log, newest 200 kept). Entries dated inside a report's period appear in that report, so agency work lands on the retainer report that covers it.
Branding follows Dashboard Branding. The header logo is the resolved admin-chrome logo (AdminLogo — the client site's logo or the agency's designer logo, embedded as a data URI since dompdf runs with remote fetching disabled), the footer shows "Prepared by {agency name}" when the Agency Credit name is set, and the "Generated by WebProCMS Analytics" line only renders when the install is not white-labelled — a white-labelled agency install produces a report with no WebProCMS mention anywhere. The settings card has Send test to me (emails the current admin, ignoring the toggle/recipients/marker) and Download PDF buttons, both for the last complete period, so the operator can check the branding before enabling the schedule.
Traffic anomaly alerts
Settings → Analytics → Traffic anomaly alerts (off by default) emails admins when yesterday's page views or goal conversions deviate from their baseline by more than the configured threshold (default 50%, band 10–500%). The baseline is the mean of the same weekday over the previous 4 weeks — a quiet Sunday is compared against other Sundays, so normal weekly seasonality never trips it. Guardrails against noise: zero-traffic baseline days are excluded from the mean (a new install's empty history must not read as a permanent spike), at least two non-zero baseline days are required, and baselines below a volume floor (50 views / 5 conversions per day) never alert at all.
LazyCron fires analytics:check-anomalies hourly so the alert lands early in the day; the analytics.anomaly_last_checked_date marker keeps it to one verdict per day (set whether or not an anomaly was found — no re-litigating the same day). The email names each anomalous metric with yesterday's number, the typical weekday number, the signed deviation, and a plain-language hint (spike → campaign/mention; drop → outage/broken page/lost ranking) plus a link to the dashboard.
Member retention (Memberships installs)
Dashboard → Analytics → Retention (sidebar link appears only when Memberships is enabled) shows signup cohorts for the last 12 months: each month's signups and where those members stand today — still active, paused, cancelled — with a retention bar (share still active), plus summary cards (total / active / paused / cancelled / new in 30 days). A "Seen (30d)" column appears when member last-seen data exists. This is deliberately current-standing retention, not a historical month-by-month curve — the CMS stores no per-member status history, and the page says so rather than faking one.
Experiments
Dashboard → Analytics → Experiments surfaces the shared A/B testing engine's rollups (ab_daily_stats — see ab-testing.md) in one place, windowed by the shared range picker: every experiment that recorded data, its variants with exposures / conversions / conversion rate, a "Leading" badge on the best-converting variant, and the two-proportion z-test confidence between the two most-shown variants (shown as "Collecting data" until the sample is big enough for the test to mean anything; 95%+ is the usual bar for declaring a winner). Popups is the first scope feeding it; future consumers of the A/B engine appear automatically.
UTM link builder
The bottom of the Sources page carries a pure client-side UTM link builder: destination URL plus source / medium / campaign / term / content fields, a live-assembled tagged URL, and a copy button. Nothing is stored server-side — it exists so the links whose visitors and conversions the Sources page attributes are built with the right parameters in the first place.
Built-in Google Analytics 4
A single field under Settings → Integrations — Google Analytics 4 Measurement ID (format G-XXXXXXXX). When set, the public layout auto-injects the standard gtag.js bootstrap into <head> on every public page — no Snippets-page paste required. Leave it blank to disable.
GA4 auto-captures utm_source/medium/campaign/term/content from the URL on every pageview, so UTM relay works out of the box with no extra wiring.
Consent gating is automatic. When the Cookie Consent feature is enabled, the GA snippet is wrapped in the standard <template data-consent-category="analytics"> shim — the gtag script (and its cookies) only execute after the visitor grants the Analytics category. When Cookie Consent is off, the script runs unconditionally for every visitor; the Settings UI surfaces an amber warning in that case telling the operator to verify their jurisdiction.
The integration is intentionally minimal: no Tag Manager bootstrap, no GA4 ecommerce events, no offline conversion upload. For those, paste a custom snippet via the Snippets page (categorized to Analytics so consent gating still applies).
REST API access
When the API feature is enabled, a token with the analytics:read ability can pull the same numbers programmatically: GET /api/v1/analytics/{summary,timeseries,pages,referrers,sources,conversions}. Every endpoint accepts range=7|30|90 or from/to (Y-m-d, clamped server-side to two years — the same clamp rules as the dashboard, though the API only offers the rolling presets and custom range, not the calendar This month / Last month / Year to date shortcuts) and returns aggregate JSON straight from the rollup tables — never a raw hit, and never a visitor hash, on any endpoint. Routes are double-gated: the feature:analytics middleware 404s the whole group when Analytics is off (so a disabled feature never leaks a hint that the route exists), and RequireApiAbility then checks the token's ability. sources and conversions follow the same per-visit / first-touch / last-touch cohort windowing rules as the dashboard's Sources page.
Shared public stats page
Off by default. Settings → Analytics → Shared stats page publishes a Plausible-style read-only page at /stats/{token} — no login required, just the 40-character secret token in the URL. The page shows the last 30 days only: views, unique visitors, a sparkline, top 10 pages, top 10 referrers, and top 10 countries (when country data exists). It deliberately never shows conversions, revenue, or search terms — those stay business-sensitive and dashboard-only.
The page is served noindex, rate-limited to 30 requests per minute per IP (so the token can't be brute-forced at any useful speed), and its own computed payload is cached for 5 minutes. The share URL excludes itself from pageview tracking (visiting the stats link never shows up in its own numbers, which would otherwise leak the secret token into the page_views table). The toggle takes effect immediately rather than needing the Settings page's Save button — turning it off kills the link right away — and a "Regenerate link" action mints a fresh token, instantly invalidating every copy of the old URL.
Elsewhere in the CMS
Two other feature modules consume this same attribution data. Marketing can auto-tag campaign email links (utm_source=email&utm_medium=campaign&utm_campaign={slug}, per-campaign toggle, on by default, external links never tagged, an author's own utm_* always wins) so a send-to-click-to-conversion path is attributable without hand-tagging every link — see marketing.md. CRM stamps every new contact with their first-touch UTM/source/referrer/landing page at creation time (via the same visitor hash), surfaced as a Lead Source card on the contact record and a leads-by-source breakdown on CRM Reports — see crm.md.
Schema
Thirteen tables, all under the analytics feature module's migrations (the old form_conversions table was folded into conversions and dropped):
| Table | Shape | Purpose |
|---|---|---|
page_views |
(date, path, referrer_domain, views, unique_visitors), unique on the first three |
Aggregated counters; one row per (date, path, referrer) combination |
page_view_visitors |
(date, visitor_hash), primary key both |
Same-day dedup ring; pruned every 7 days |
page_view_campaigns |
(date, utm_source, utm_medium, utm_campaign, views, unique_visitors), primary key on the first four |
Per-visit campaign rollup powering the industry-standard Per-visit view on the Sources page; empty-string UTMs aggregate into the "Direct / None" row; pruned by retention setting |
page_view_countries |
(date, country, views), unique on (date, country) |
Daily views per ISO-3166 alpha-2 country code, sourced from CDN headers only; no row when no CDN header is present; pruned by retention setting |
page_view_devices |
(date, device, browser, views), unique on (date, device, browser) |
Daily views per coarse device (mobile/tablet/desktop) and browser family bucket, classified server-side; the raw user agent is never stored; pruned by retention setting |
not_found_hits |
(date, path, referrer_domain, hits), unique on the first three |
Daily rollup of real 404s by missing path + referrer domain; pruned by retention setting |
page_engagement |
(date, path, samples, engaged_seconds_sum, bounce_count, scroll_depth_sum), unique on (date, path) |
Engagement-beacon rollup (opt-in) — average engaged time and bounce rate are derived by dividing the sums by samples; no per-visitor rows; pruned by retention setting |
realtime_hits |
(created_at, path, referrer_domain, visitor_hash, utm_source, utm_medium, utm_campaign), indexed on created_at |
Per-event log for the Realtime view; UTM columns let the realtime page show which campaigns are active right now; pruned every 5 minutes (last 30 min retained) |
search_queries |
(created_at, source, term, results_count) |
Per-search log for the Search dashboard, across site, real_estate, and shop sources; pruned by retention setting |
conversions |
(goal_type, reference_id, value_cents, created_at, date, path, visitor_hash, first_utm_source/medium/campaign, last_utm_source/medium/campaign) |
The goal registry — one row per conversion of any type (form, order, subscriber, lead, member_signup, booking, donation), with denormalized first- and last-touch UTMs; reference_id has no DB-level foreign key so a conversion outlives its referent; pruned by retention setting |
visitor_attributions |
(visitor_hash PK, first_seen_at, first_utm_*, first_click_id_source, first_click_id, first_landing_path, first_referrer_domain, last_seen_at, last_utm_*, last_click_id_source, last_click_id, last_landing_path, last_referrer_domain) |
One row per UTM-tagged visitor; first-touch fields lock on insert, last-touch fields update each subsequent visit; indexed on first/last campaign tuples for fast aggregation; pruned by retention setting (cutoff against last_seen_at) |
analytics_funnels |
(id, name, steps JSON) |
Funnel definitions — ordered steps, each {type: path|goal, value, label}; operator config, never pruned or wiped |
funnel_hits |
(funnel_id, step_index, visitor_hash, date, created_at), primary key on the first four |
Per-visitor evidence for funnel path steps (goal steps read conversions directly); one row per visitor per step per day; pruned by retention setting |
Cardinality hardening. The rollup tables keyed on request-controlled fields (referrer domain, UTM values, 404 path, engagement-beacon path) each have a per-day distinct-value ceiling of 5,000: once a day crosses it, further new values fold into a single (other) bucket (shown in the dashboards as a non-clickable "Other (grouped)" row) rather than creating unbounded rows. page_views' ceiling counts distinct non-empty referrer domains — direct traffic and a large page count never consume it. page_view_campaigns and not_found_hits count plain rows (their rows already are the distinct attacker-mintable tuples). visitor_attributions has a matching per-day ceiling on new attribution rows (existing visitors' last-touch updates always run). page_engagement caps distinct new paths per day, mirroring not_found_hits. Direct/untagged traffic never folds. Two dimensions are deliberately not capped: page_view_countries (bounded to ~250 real ISO-3166 codes) and page_view_devices (bounded to 3 devices × 7 browsers) — neither is attacker-mintable, since both come from a fixed classification enum rather than a raw request value.
Dashboard surfaces
All pages below require at least the Manager role; Settings requires Admin. Every page except Realtime shares the named date-range picker described above and has a CSV download button next to it, built on a shared formula-injection-hardened exporter (CsvExport) so a referrer domain, UTM value, or search term that starts with =, +, -, or @ can't execute as a spreadsheet formula when an admin opens the file.
- Overview — summary cards (views, daily unique visitors, views per visitor-day, with a delta vs. prior period computed over complete days so a partial today doesn't read as a dip), an inline SVG sparkline of daily views with a dashed previous-period comparison line on the same scale, activity-log annotation markers on days with content changes (publish/update, tooltip on hover, capped at 20), top 10 pages, top 10 referrers.
- Realtime — auto-polls every 5 seconds. Shows active visitors and hits in the last 5 minutes, top paths right now, and a feed of the last 30 minutes of hits with relative time + path + source + campaign. When the realtime toggle is off in Settings, the page says so instead of pretending the site is silent.
- Pages — sortable table of every page (both directions) with views and entrances; gains Avg. time and Bounce % columns (and matching sort options) once the engagement beacon has recorded data for the active range.
- Referrers — direct vs. referred breakdown with percentages, then top external domains with entrances.
- Sources — top sources/campaigns by visits and conversions, with a three-way Per visit | First touch | Last touch attribution toggle. Per-visit mode groups directly from
page_view_campaignsso "Direct / None" shows as a real row alongside UTM-tagged campaigns (industry-standard cookieless pattern). First/Last-touch modes group fromvisitor_attributionsand show only tagged campaigns. Gains Orders and Revenue columns when Ecommerce is enabled (see "Revenue per source" above). Summary cards show the visitor/conversion/order totals appropriate to the selected mode. - Conversions — one summary card per goal type that has data or an enabled owning feature (Forms always shows; Orders needs Ecommerce; Subscribers needs Marketing; Leads needs Real Estate; Members needs Memberships; Bookings needs Online Booking; Donations needs Donations — the Orders, Bookings, and Donations cards also show the range's revenue sum), a goal filter pill row, a conversions-over-time sparkline, a recent-conversions table (with deleted-referent fallback labels like "(deleted form)"), and a per-form breakdown table linking to the form editor.
- Funnels — funnel definitions with per-step visitor bars, drop-off percentages, and the funnel completion rate; create/edit/delete lives on the page. See "Multi-step funnels" above.
- Experiments — A/B rollups per experiment with per-variant exposures/conversions/rate, a leader badge, and z-test confidence. See "Experiments" above.
- Retention — member signup cohorts (Memberships installs only). See "Member retention" above.
- Search — top searched terms across all three search sources, with searches count, average results count (terms with avg < 1 highlighted as "no-result searches" — content gaps), and last-seen date. Below the on-site table sits the Google Search performance panel: the search terms people typed into Google before clicking through (top queries and top pages with clicks, impressions, CTR, and average position, plus a clicks-per-day sparkline over the last 28 days). It needs a one-time Google Search Console connection — the setup form (OAuth client + Connect button) lives right on the page, and the connection is shared with the SEO group's Search Console page: set it up in either place and it works in both. Search Console is a paid SEO Pro capability — with the
seo_scanneraddon off, this panel renders the SEO Pro teaser instead and the OAuth routes 404 (disconnect stays available so a lapsed install can sever the Google connection). - Countries — views by country with flag emoji, localized country names, and percentage bars. The empty state explains that country data needs the site to sit behind Cloudflare, CloudFront, or Vercel.
- Devices — device (mobile/tablet/desktop) and browser-family breakdowns with percentage bars, plus a "Mobile share: N%" summary line.
- Not Found (404s) — missing paths with hit counts and referrers, each with a shortcut to create a redirect in the Redirects manager.
- Settings — see above.
Sidebar entry
Lives between Content and Manage as its own expandable group when the feature is enabled, with the report sub-links above visible to Manager and above (Retention only when Memberships is also enabled), plus Settings visible to Admin and above. The Realtime link shows a live "online now" badge (visitors active in the last 5 minutes, cached for 60 seconds) whenever realtime tracking is on. Hidden entirely on installs without an active membership or with the toggle off.
Privacy posture
The product story is "real analytics, no compliance overhead":
- No cookies set by default. No
Set-Cookieheader from the analytics path in the default configuration. (When the optional persistent visitor cookie is explicitly enabled — and only then, and only after the visitor accepts the Cookie Consent banner's Analytics category — a single first-partywpcms_vidcookie containing a random UUID is set.) - No raw IP addresses persisted. Two salted SHA-256 hashes serve distinct purposes: the daily-rotating hash (
page_view_visitors) embeds the current date so the same visitor on a different day produces a different hash and cannot be linked across days; the stable hash (visitor_attributions,conversions) embeds an "attribution" salt instead of the date and is stable across days for the sameIP | user-agent— required so a Monday ad click can be credited for a Wednesday goal conversion. Both also include the app key, so neither is a cross-site identifier; both are pseudonymous (no PII) and one-way. - No paid geolocation and no fingerprinting. Country is a two-letter code read from a CDN-supplied header only — never a purchased GeoIP database, never resolved to city, region, or coordinates — and is simply absent on installs not sitting behind Cloudflare, CloudFront, or Vercel. Device and browser are one of a fixed set of coarse buckets classified from the user agent, which is then discarded; there is no fingerprint surface (no viewport, screen size, language headers, color scheme, installed fonts, or plugins).
- No external network call in the default configuration. Everything stays inside the install's database, including everything served over the REST API and the shared public stats page — neither exposes a visitor hash or any per-visitor row, only pre-aggregated rollups.
- UTM capture is server-side only. The middleware reads
utm_*and click-ID parameters from the URL — no client-side JavaScript is required, no cookie is set, and no consent banner is needed for the analytics path itself. - The engagement beacon is the one opt-in exception that adds a script tag and a client-side measurement. It's off by default, sets no cookies, honors Do-Not-Track/GPC on both the client and server, is consent-gated behind the Analytics category when Cookie Consent is enabled, and never stores a per-visitor row — only a daily per-path aggregate.
- Data minimization. Retention is configurable and defaults to 365 days. Rows beyond the window get pruned daily across every rollup table —
visitor_attributionsrows are aged out againstlast_seen_atso a UTM-tagged visitor who hasn't returned in 365 days falls off naturally. - Mostly easy to wipe. The Settings page has a one-click "Clear all analytics data" button that truncates the pageview, visitor, campaign, realtime, search, conversion, attribution, and engagement tables (eight of the eleven). Country, device, and 404 rollups aren't included in that button yet and instead age out via the normal retention window.
For most jurisdictions (including GDPR), this profile means no cookie banner is required to enable analytics on a WebProCMS site in its default configuration. Enabling the built-in GA4 relay, the optional persistent visitor cookie, or the engagement beacon changes that for the pieces they touch — the persistent cookie toggle is physically locked behind Cookie Consent, the GA snippet auto-gates on it when present and surfaces an amber warning when not, and the beacon is consent-gated the same way when Cookie Consent is on. The operator should still review their own legal posture, but the default design intent is to stay below the threshold that triggers consent obligations.
Comparison
| WebProCMS Analytics | Google Analytics 4 | Plausible | Matomo (self-hosted) | |
|---|---|---|---|---|
| Where data lives | Your install's DB | Plausible's servers (or self-host) | Your server (separate app) | |
| Cookies | None by default | Yes (and consent banner) | None | None |
| External script tag | None (opt-in first-party beacon only) | Yes (~46 KB) | Yes (~1 KB) | Yes (~22 KB) |
| Inside the dashboard? | Yes | No (separate site) | No | No |
| Setup steps | 0 | GA property + script + consent banner + Tag Manager | Account or self-host | Self-host + script tag |
| Cost | Included with membership | Free (with Google data sharing) | $9+/mo | Free + your hosting |
| Realtime view | Yes | Yes | Yes | Yes |
| On-site search tracking | Yes (site, shop, and real-estate search) | Manual setup | Manual setup | Manual setup |
| Goal conversions | Yes (auto — forms, orders, signups, leads) | Manual setup (GA4 events) | Manual setup | Manual setup |
| UTM / campaign attribution | Yes (Per-visit + first-touch + last-touch, cookieless) | Yes (cookies + consent banner required) | Yes (cookieless, per-visit) | Yes (cookies optional) |
| Revenue / ROI per source | Yes (Ecommerce installs, net of refunds) | Yes (requires GA4 ecommerce event wiring) | Add-on (paid plans) | Yes (Ecommerce plugin) |
| "Direct / None" in source breakdown | Yes (real row in Per-visit mode) | Yes | Yes | Yes |
| Countries / devices | Yes (CDN-header country; coarse device/browser buckets) | Yes (full geo + device detail) | Yes | Yes |
| Time-on-page / scroll / bounce | Opt-in beacon only, off by default | Yes | Yes (bounce only, no scroll) | Yes |
| REST API access | Yes (own API feature, analytics:read ability) |
Yes (Google's Reporting API) | Yes (paid plans) | Yes |
| Shareable public stats page | Yes (tokened link, traffic-only) | No (requires a Google account + property access) | Yes | Yes (public dashboards, paid) |
| Weekly email report | Yes, on by default | Yes (manual scheduled reports) | Yes (paid plans) | Yes (scheduled reports) |
The trade: WebProCMS analytics doesn't have GA's depth (full audience reports, attribution modeling, ecommerce funnels, custom event tracking). It covers the large majority of what an editor or small-business owner actually looks at, with no setup and no third-party cost.