WP Manifestindependent plugin directory
manifest / analytics / sitepulse-analytics

SitePulse Analytics WP Registry grade A. No security issues foundWP RegistryNo security issues foundOpen the reportA

Custom analytics tracker for Wordpress sites that can generate reports and send analytics data to API endpoints

★ 0stars
v1.8.0latest · 3mo ago

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/magellan-web-dev/sitepulse-analytics/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

A self-hosted visitor analytics plugin for WordPress. SitePulse tracks page views, link and button clicks, form submissions, confirmed form conversions with campaign attribution, mouse hover activity, and scroll depth, then surfaces everything inside the WordPress dashboard — making it easy to identify popular pages, which campaigns and channels actually produce leads, and the areas of your content visitors engage with. On a configurable schedule, aggregated analytics (including individual attributed conversions) can also be delivered as JSON POST requests to one or more webhook endpoints.

Features

  • Page view tracking — every frontend page load is recorded with URL, title, and referrer.
  • Click tracking — links, buttons, submit inputs, and role="button" elements, with the element's label and destination URL.
  • Form submission tracking — native submit events are captured before any AJAX handler can swallow them, so Elementor and similar AJAX forms are counted too. Counted at submit time, so these are submission attempts, not confirmed successes.
  • Confirmed conversions — a separate form_success event fires only when the form plugin reports that the server accepted the submission: Elementor Pro's submit_success, Contact Form 7's wpcf7mailsent, WPForms' wpformsAjaxSubmitSuccess, and Gravity Forms' gform_confirmation_loaded, plus a spa:conversion DOM event for custom goals. These integrations detect AJAX submissions — the events above fire on the AJAX success response. A traditional (non-AJAX) submission that navigates to a new page has no success event the tracker can observe, so forms configured that way are counted as attempts but not as confirmed conversions; use the spa:conversion event (e.g. on a thank-you page) for those. Every conversion carries a unique conversion id (duplicates from retried deliveries are never double-counted — the REST endpoint rejects a form_success without a valid, bounded id) and a snapshot of the session's campaign attribution at conversion time, so each conversion record is self-contained — even when the tagged landing happened earlier in the session or the campaign data has since aged out.
  • Campaign attribution — all six utm parameters (utm_source, utm_medium, utm_campaign, utm_id, utm_term, utm_content) are captured from tagged landing URLs, and ad-click identifiers (gclid, gbraid, wbraid, fbclid, msclkid, ttclid, twclid, li_fat_id) are recognized: only the parameter name is stored (never the value), and when no utm tags are present the implied source/medium is filled in (e.g. gclid → google / cpc). Attribution is last-touch within the session, and the snapshot rides on every event in the session — pageviews and conversions, but also clicks, form attempts, hovers, and scroll milestones — so intermediate funnel steps can be segmented by campaign. Untagged acquisition persists too: the referrer a session entered through is stored client-side and sent as session_referrer, so an organic or referral visit keeps its channel across internal navigation instead of degrading to Direct at conversion time.
  • Channel grouping — every attributed event is classified into a marketing channel at ingestion (Paid Search, Paid Social, Organic Search, Organic Social, Email, Display, Affiliate, SMS, Referral, Direct, Other), with source aliases normalized (fb / facebook.com → facebook) so reports don't fragment. When an event's own referrer is internal or missing, classification falls back to the session's persisted entrance referrer (session_referrer). Referrer hosts match a search engine or social network only in the registrable-domain position (www.google.co.uk matches; a lookalike like google.example.test does not). Both the alias map and the channel rules are filterable.
  • Hover tracking — records when a visitor's pointer rests on an interactive element for a configurable dwell time (default 800 ms), once per element per page view. Add data-spa-hover to any element — images included — to opt it into hover tracking.
  • Scroll depth — 50/100% milestones, once each per page view.
  • Custom server-side events — record anything else with spa_track_event().
  • Dashboard analytics — summary cards, an accessible daily page-view chart (single-Tab-stop keyboard navigation, touch/mouse tooltips, visible axes, and a data-table fallback), a print-optimized Print / Save as PDF report view, top pages, top landing pages, top clicked elements, top forms, most-hovered elements, top referrers, campaign performance (sessions, conversions, session conversion rate), a keyword/creative drilldown (utm_term / utm_content), a channel breakdown, a device breakdown, and a recent-activity feed. Reports are grouped into an always-visible Overview plus collapsible Content, Engagement, Acquisition, Devices, and Recent Activity sections, filterable by 7/30/90-day periods (calendar days, UTC, with the exact date range shown).
  • Webhook delivery — aggregated analytics sent as JSON to any number of endpoints (HTTPS required; a filter allows HTTP for development) on an hourly, twice-daily, daily, or weekly schedule, with per-endpoint delivery windows, optional HMAC-signed requests (shared or per-endpoint signing secrets), optional history backfill for newly added endpoints, automatic retries on failure, a manual test-send button, and a delivery log. Conversion delivery is lossless: windows holding more than 100 individual conversions are split into consecutive deliveries instead of dropping the overflow.
  • Bounded storage — a daily cleanup cron deletes events older than the configured retention window (default 90 days, adjustable 7–365).
  • GitHub-powered updates — new releases published to the GitHub repository appear as standard update notifications on the Plugins screen.

Installation

  1. Copy the sitepulse-analytics folder into wp-content/plugins/ (the folder name must remain sitepulse-analytics).
  2. Activate SitePulse Analytics on the Plugins screen. Activation creates the events table and schedules the cleanup and webhook cron events.
  3. Visit SitePulse in the admin menu to view analytics, and SitePulse → Settings to configure tracking and webhooks. SitePulse → About carries this documentation inside wp-admin.

How tracking works

A single dependency-free script (assets/js/tracker.js) is enqueued deferred in the footer of frontend pages. It batches events in memory and delivers them to a public REST endpoint:

POST /wp-json/sitepulse/v1/track
Content-Type: application/json

{"batch_id": "b3f2a9c1b8e0d", "events": [{"type": "pageview", "page_url": "...", "page_title": "...", ...}]}

Batches flush every 5 seconds, when 20 events accumulate, and on page exit via navigator.sendBeacon, so events are not lost when a visitor navigates away. Every batch is persisted before it is sent — to a bounded sessionStorage map keyed by batch id, with an in-memory map standing in when sessionStorage is unavailable — and removed only when the server acknowledges it (a 2xx, or a 4xx other than 429 that retrying cannot fix). A batch whose page is destroyed before the response arrives, or that fails with a network error, a 5xx, a 429 (rate limited), or a 503 (events could not be stored — the endpoint refuses to acknowledge what never reached the database), is therefore resent by a later flush — by the same page or by the next page in that tab. Each batch is removed only by its own acknowledgment, so one batch's success can never discard another's undelivered events. Fetch-delivered batches are at-least-once, and replays are idempotent: the batch_id travels in the request body and rows are stored under a unique (batch_id, event ordinal) database key, so a replayed batch whose response was lost is deduplicated server-side instead of inflating page-view, click, and conversion counts. Fresh page-exit batches handed to navigator.sendBeacon are the exception: an accepted hand-off only means the browser queued the request, so those are treated as delivered — best-effort — because keeping every one persisted would resend every exit batch on the next page. A batch that already survived a failed or unacknowledged send, however, stays persisted even through an accepted beacon hand-off — its loss is exactly what retrying exists to prevent, and the server-side batch dedup absorbs the resend if the beacon did land.

The endpoint defends itself in layers: it accepts only whitelisted, currently-enabled event types; requires tracked page URLs to be http(s) URLs on this site's host (foreign schemes, and ports the site doesn't actually run on, are rejected or normalized away); rejects requests whose Origin/Referer header names a foreign host; ignores known bots and empty user agents; drops batches carrying DNT: 1 / Sec-GPC: 1 headers when the privacy-signal option is on (server-side backstop for the tracker's own check); caps request body and batch sizes; requires scalar field values and sanitizes/truncates every one; and rate-limits by event count — 300 events per IP per minute plus a site-wide cap of 3,000/minute, both tunable via the spa_rate_limits filter — using atomic object-cache counters when a persistent object cache is available, and an atomic single-statement database counter otherwise (also the fail-closed fallback whenever a cache increment fails — a flaky cache never disables the limit), so the caps are a hard bound on stored volume even under heavy concurrency. The per-IP check runs first and a rejected sender never consumes the site-wide budget, so one flooding IP can't starve legitimate visitors out of the global allowance. Accepted events are written with a single multi-row INSERT per request. The IP is used only as a hashed, short-lived rate-limit key and is never stored with analytics data (behind a reverse proxy, map the real client IP with the spa_client_ip filter). If the site-wide cap is reached, a warning appears on the dashboard for 24 hours so dropped events don't go unnoticed. For very-high-traffic sites, raise the limits via the filter and consider edge/WAF protection in front of the endpoint.

Privacy posture

  • No cookies are set. The session identifier lives in localStorage and rotates after 30 minutes of inactivity, so it groups one visit (across tabs) without becoming a persistent visitor ID.
  • Tracked URLs are canonicalized to scheme + host + path — no query strings are ever stored, so search terms, tokens, and emails in a page URL's or referrer's query string never reach the database. The session's entrance referrer (session_referrer) is normalized the same way and is used only to classify the marketing channel at ingestion — it is never stored as its own field.
  • What is retained, stated precisely: campaign parameter values (utm_source, utm_medium, utm_campaign, utm_id, utm_term, utm_content) are stored after text sanitization (source/medium are lowercased and alias-normalized) — except values containing an @, which are dropped as likely email addresses — so never put personal information in UTM parameters. Ad-click identifiers (gclid, fbclid, …) are handled more strictly: only the parameter name is stored (as click_id_type); the identifier value is a cross-site advertising ID that may qualify as personal data, so the tracker never even sends it. Clicked mailto: and tel: destinations are stored whole (the address/number is the destination); if that is unacceptable for your site, disable click tracking or strip target_url via the spa_tracked_event filter.
  • Optional: honor Do Not Track / Global Privacy Control browser signals (off by default; toggle under Settings → Tracking). Enforced both in the tracker (it never starts) and at the REST endpoint (batches carrying DNT: 1 / Sec-GPC: 1 are discarded). Note DNT/GPC is an opt-out signal, not a consent mechanism — if your jurisdiction requires consent, gate the tracker with your consent tool.
  • What page-level data can still contain, stated plainly: URL paths are stored, and a path itself can embed personal data if your site puts it there (e.g. /reset/<token>/, /account/jane@example.com/, /order/1234/) — the same applies to page titles, clicked element labels, and form names, which are stored as visitors see them. If your URLs or titles carry personal data, exclude or rewrite those fields with the spa_tracked_event filter before storage.
  • The webhook signing secret is stored in the plugin's settings option (like other WordPress secrets such as SMTP credentials, it is readable by anyone with database access or manage_options) and is rendered back into the settings field for editing. Rotate it if you believe an admin session or database copy was exposed.
  • The Delivery Log retains each delivery's full request payload and the endpoint's response. Structured JSON responses have sensitive-looking keys redacted automatically; non-JSON response bodies are stored as received (truncated to 64 KB). Use the spa_delivery_log_row filter for site-specific redaction, or skip logging entirely.
  • No IP addresses or user agents are stored — only a coarse mobile/desktop device bucket.
  • Logged-in users are excluded from tracking by default (toggleable in settings).
  • Data is automatically deleted after the retention window.

Dashboard

SitePulse (top-level admin menu) shows, for the selected 7/30/90-day period (UTC calendar days — the exact date range is displayed next to the period selector, and the current day is marked as still collecting). Reports are grouped into an always-visible Overview plus collapsible Content, Engagement, Acquisition, Devices, and Recent Activity sections — native, keyboard-accessible panels that keep the page scannable without hiding any report. Expand all / Collapse all controls sit above the panels, each panel remembers its open/closed state for the browsing session (including across period changes), and a Print / Save as PDF button produces a print-optimized report of the selected period through the browser's native print dialog, expanding every section and chart value automatically:

Section What it tells you
Summary cards Totals for page views, clicks, form submit attempts, confirmed conversions, hovers, and scroll milestones — with short explanations under the less obvious metrics
Daily Page Views An accessible bar chart of traffic over the period: every day is reachable by mouse, keyboard (one Tab stop, then Arrow/Home/End/Page keys), or touch with an exact-count tooltip, framed by a pinned Y-axis scale and date labels, with a total / average-per-completed-day / busiest-day summary, a patterned marker for the still-collecting current day (visible even at zero views), and a "View data table" fallback that works without JavaScript. Dense 30/90-day views scroll horizontally behind a pinned Y-axis so every day keeps at least a 14px interaction width
Top Pages Most-viewed pages with view and session counts (sessions use a 30-minute inactivity window)
Top Landing Pages The first page of each session that started in the period — where visitors actually arrive
Top Clicked Elements Which links and buttons visitors click most — your conversion actions
Top Form Submit Attempts Which forms are submitted, and on which pages (counted at submit time; success not confirmed)
Most Hovered Elements Where visitor attention lingers
Top Referrers Which external sites and pages send you traffic
Campaigns Sessions, views, confirmed conversions, and session conversion rate (sessions with ≥ 1 conversion ÷ sessions) per utm_source / utm_medium / utm_campaign / utm_id. Attribution is last-touch within the session: the most recent tagged landing attributes the visit from that point on, and untagged pages inherit it
Campaign Terms & Content Keyword (utm_term) and creative (utm_content) performance with campaign context — always listed, with an explanatory empty state when the period has no traffic carrying those tags
Channels Sessions, views, confirmed conversions, and session conversion rate per marketing channel (Paid Search, Organic Social, Email, Referral, Direct, …), classified at ingestion
Devices Mobile vs desktop share of page views
Recent Activity The latest 15 raw events — independent of the selected period, shown in the site timezone using the site's date/time display settings — for verifying tracking is working

Webhooks

The Settings page opens with Webhook Status and Webhook Settings at the top (Tracking and Data settings follow below, and Test Delivery below that). Configure endpoints in Webhook Settings: each endpoint gets its own block with a URL field (HTTPS required — payloads carry visitor analytics; development setups can allow HTTP via the spa_allow_insecure_webhooks filter), an optional label (shown as a badge on Delivery Log entries so a specific endpoint is easy to spot), and an optional per-endpoint signing secret, and the + Add Additional URL button adds as many as you need; every block but the first has a Remove button. A Webhook Status toggle above it pauses all new scheduled deliveries without discarding your configured endpoints — it appears once at least one endpoint has a URL, mirroring the layout of the Forms Webhook Integrator plugin used elsewhere on this site. The same card also has optional Client First Name, Client Last Name, Client ID, and Website ID fields, sent as website_info.client.first_name/last_name/id and website_info.id in every payload — handy for identifying which client or site a payload belongs to when one endpoint receives deliveries from several installs. On each scheduled run every endpoint receives a POST with Content-Type: application/json. Delivery windows are tracked per endpoint: each payload covers the time since that endpoint's last successful delivery, so a temporarily failing endpoint receives the full missed window on the next run instead of losing data. A gap longer than one send interval — downtime, a paused Webhook Status toggle, or a backfilled first send — is delivered as consecutive interval-sized windows (up to 10 per run; the remainder resumes on the next run), so history arrives at the same granularity as live deliveries.

The same card also offers:

  • Signing secret (optional) — when set, every webhook request carries an X-SPA-Signature header: sha256=<hex>, the HMAC-SHA256 of the raw JSON body keyed with the secret. The receiver recomputes the HMAC over the exact bytes it received and compares (use a constant-time comparison such as PHP's hash_equals()), verifying both authenticity and integrity. Signatures are computed at send time, so retried deliveries re-sign the identical frozen bytes. An endpoint block's own signing secret overrides the shared one for that endpoint, so one receiver never learns the key that signs payloads for the others.
  • History backfill (optional, off by default) — when enabled, an endpoint that has never received a delivery starts from the beginning of the retention window instead of one send interval ago, so an endpoint added months after data collection began still receives everything the site retains. Enabling it after an endpoint has already received deliveries changes nothing for that endpoint.

Each top_* list in the payload holds up to 200 rows (tunable via the spa_webhook_report_limit filter) — much deeper than the dashboard's top 10 — so a downstream system aggregating deliveries long-term sees (near-)complete page, click, form, referrer, and campaign rankings per window.

Delivery scatter

To avoid a thundering herd when many sites running this plugin share one endpoint and the same interval, each site's schedule is anchored at a random offset within its send interval (capped at 24 hours) instead of at the moment of activation. The offset is stable — a given site always delivers at roughly the same time — and is re-randomized only when the interval setting changes or the plugin is reactivated. The settings page shows the next scheduled send time.

Dispatch concurrency

Only one dispatch run (and one retry) executes at a time: every run takes a site-wide mutex before reading any delivery marker — a MySQL named lock (released automatically if PHP dies mid-run, and named per install by hashing the database name, table prefix, and site URL, so unrelated WordPress installs sharing one database server never contend), with an atomic option-row lock as fallback. The fallback lock carries an ownership token and a lease: the holder renews the lease between delivery windows (so a legitimately long multi-endpoint run is never mistaken for a dead one), only a lock whose holder stopped renewing can be stolen, and both release and steal are compare-and-delete operations on the token — a run whose lock was stolen can never free the new holder's lock. A long run that overlaps the next cron trigger therefore can't produce overlapping windows under different delivery_id values; the overlapping run simply yields. Each run also captures a single delivery horizon before iterating endpoints, so caught-up endpoints share identical windows — and one computed summary — instead of re-running the ~17 aggregate queries per endpoint, keeping multi-endpoint runs short. If the payload ever fails to JSON-encode (e.g. a spa_webhook_payload filter introduced an unencodable value), the delivery is treated as a failed attempt and enters the normal retry chain — an empty body is never sent (so a lenient endpoint's 2xx can't advance the marker past a window that was never delivered), and the payload is never quietly rebuilt without the filter, since the filter may exist to redact data.

Retry handling

Failed webhook deliveries (a transport error or any non-2xx status) are retried automatically up to 5 more times over about 24 hours — after 5 minutes, 30 minutes, 2 hours, 6 hours, and 16 hours. The JSON body that failed is frozen — serialized and stored with the retry state — so every attempt in the chain re-sends the literally byte-identical payload under the same delivery_id / Idempotency-Key (retention cleanup, settings changes, or a plugin update between attempts cannot alter it), and scheduled runs skip an endpoint while its chain is actively pending.

If the whole chain is exhausted (or a retry cron cannot be scheduled), the frozen delivery is kept: the next scheduled run re-sends that exact body under the same delivery_id first, restarting the retry chain if it fails again. Deactivating the plugin suspends pending retries the same way — their frozen deliveries are kept and resumed by the first scheduled dispatch after reactivation, still under their original delivery_id. Only after the frozen delivery is acknowledged does the endpoint's marker advance — exactly to the frozen window's end — and newer events go out (in the same run, as a separate delivery covering the window from the frozen end onward). Consecutive deliveries therefore never cover overlapping windows. Delivery is at-least-once, and any duplicate a receiver can ever see carries a delivery_id it has already processed — deduplicating by delivery_id is sufficient to never double-count. Pending retries and every attempt's outcome are shown on the settings page.

Every mutation of the retry bookkeeping happens while holding the dispatch mutex. A retry that finds the mutex taken only re-schedules its own cron event — it never writes shared state that the lock holder may be clearing or replacing — and if even that re-schedule fails, the chain is detected as orphaned by the next dispatch run (pending state with no cron event behind it, well past due) and resumed under its original frozen bytes and delivery_id. A retry that fires after its delivery was already acknowledged finds no stored state and stops without sending. Frozen deliveries also don't outlive the data itself: a chain frozen longer ago than the retention window is discarded automatically, and each pending retry has an explicit Discard action on the settings page (discarding never loses site data — the endpoint's next delivery covers that window again, under a new delivery_id).

Example payload:

{
    "source": "sitepulse-analytics",
    "plugin_version": "1.8.0",
    "website_info": {
        "name": "Example Site",
        "url": "https://example.com",
        "id": "site-123",
        "client": { "first_name": "Jane", "last_name": "Smith", "id": "client-456" }
    },
    "generated_at": "2026-07-10T12:00:00+00:00",
    "delivery_id": "0f52c1d6a4b98e73d21f06c58a9b3e47",
    "period": {
        "start": "2026-07-09T12:00:00+00:00",
        "end": "2026-07-10T12:00:00+00:00"
    },
    "analytics": {
        "totals": { "pageview": 1240, "click": 512, "form_submit": 38, "form_success": 24, "hover": 940, "scroll_depth": 2210 },
        "daily_pageviews": [ { "date": "2026-07-09", "count": 610 }, { "date": "2026-07-10", "count": 630 } ],
        "top_pages": [ { "page_url": "https://example.com/", "page_title": "Home", "views": 400, "sessions": 310 } ],
        "top_landing_pages": [ { "page_url": "https://example.com/spring-sale/", "page_title": "Spring Sale", "sessions": 180 } ],
        "top_clicks": [ { "element_label": "Get a Quote", "element_tag": "a", "target_url": "https://example.com/quote", "clicks": 88 } ],
        "top_forms": [ { "element_label": "contact-form", "page_url": "https://example.com/contact", "submissions": 21 } ],
        "top_hovers": [ { "element_label": "Pricing", "element_tag": "a", "hovers": 130 } ],
        "top_referrers": [ { "referrer": "https://www.google.com/", "visits": 210 } ],
        "top_campaigns": [
            {
                "utm_source": "newsletter", "utm_medium": "email", "utm_campaign": "spring-sale",
                "utm_id": "cmp-2210", "channel": "Email",
                "views": 96, "sessions": 74,
                "conversions": 7, "converting_sessions": 6, "conversion_rate": 8.11
            }
        ],
        "top_campaign_content": [
            {
                "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "summer-sale",
                "utm_id": "cmp-3301", "utm_term": "emergency plumber", "utm_content": "ad-variant-b",
                "views": 42, "sessions": 31, "conversions": 3
            }
        ],
        "channels": [
            { "channel": "Paid Search", "views": 320, "sessions": 240, "conversions": 12, "converting_sessions": 11, "conversion_rate": 4.58 },
            { "channel": "Email", "views": 96, "sessions": 74, "conversions": 7, "converting_sessions": 6, "conversion_rate": 8.11 }
        ],
        "conversions": {
            "total": 24,
            "recent": [
                {
                    "conversion_id": "c1f52c1d6a4b98e73",
                    "form": "contact-form",
                    "page_url": "https://example.com/contact",
                    "referrer": "https://example.com/services",
                    "device": "desktop",
                    "occurred_at": "2026-07-10 09:14:02",
                    "attribution": {
                        "channel": "Paid Search",
                        "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "summer-sale",
                        "utm_id": "", "utm_term": "", "utm_content": "",
                        "click_id_type": "gclid"
                    }
                }
            ]
        },
        "devices": { "desktop": 820, "mobile": 420 }
    }
}

website_info.id and website_info.client (first_name, last_name, id) are optional identifiers configured under Settings → Webhook Settings; each is always present in the payload as an empty string when not set, so consumers never have to check for its existence.

analytics.conversions.recent lists every individual confirmed conversion in the delivery window (deduplicated by conversion_id), each carrying the attribution snapshot taken when the conversion occurred — so a downstream CRM or automation platform never has to search older payloads to attribute a lead. Conversion delivery is lossless: a window holding more than 100 conversions is automatically split — the delivery covers a shorter window containing the first 100, and the remainder goes out as the next delivery (consecutive, non-overlapping windows, worked off within the same dispatch run) — so no conversion record is ever skipped by a listing cap. analytics.channels and the enriched analytics.top_campaigns provide the aggregate view; conversion counts in both are deduplicated by conversion id, and conversion_rate is a session conversion rate (sessions with at least one conversion ÷ sessions, capped at 100) with the raw conversions and converting_sessions counts alongside.

Requests are sent with wp_safe_remote_post() (endpoints are re-validated at request time) and redirects disabled, and every request carries an Idempotency-Key header equal to the payload's delivery_id — plus an X-SPA-Signature HMAC header when a signing secret is configured (see above).

A Send test payload now button on the settings page delivers the last 7 days to every endpoint immediately (flagged with "test": true).

Delivery Log

SitePulse → Delivery Log records every delivery attempt — scheduled, retry, or test — in its own table, including the exact JSON payload that was sent and the response the endpoint returned. Response downloads are capped at 64 KB at the transport layer (a misbehaving endpoint can't balloon memory), stored bodies are capped at 64 KB (bytes), and values of sensitive-looking keys in JSON responses (password, token, secret, authorization, api_key, …) are replaced with [REDACTED] before storage — the log must not become a credential store. The spa_delivery_log_row filter runs before each row is written for site-specific redaction, or to skip logging an attempt entirely (return false).

The page shows two paginated lists, Successful Deliveries and Failed Deliveries, each with year/month/endpoint filters, debounced payload search, a per-page selector, and per-entry delete; entries for a labeled endpoint show its label as a badge. A toolbar offers Clear All Logs and CSV/JSON export (both include the webhook label column and stream row-by-row, so even very large logs export in bounded memory). Log entries share the analytics retention window and are pruned in bounded chunks by the same daily cleanup.

Deliveries API

The Delivery Log page can also enable a read-only REST endpoint that returns the log as JSON (off by default). Only a SHA-256 hash of the API key is stored — the raw key is shown once, right after it is generated; copy it then, or regenerate a new one (which invalidates the old key immediately):

GET /wp-json/sitepulse/v1/deliveries?page=1&per_page=25&status=error
Authorization: <api-key>

status accepts success or error (omit for all). Pagination metadata is returned in the X-WP-Total, X-WP-TotalPages, and X-SPA-Page response headers. Requests without the exact key get a 401 (repeated failures from one IP are throttled with a 429); when the API is toggled off the endpoint answers 403. In responses, endpoint_url is redacted to scheme + host — webhook URLs frequently embed bearer tokens in their path or query string, and this API's read-only key must never hand out write credentials for downstream endpoints; identify endpoints by webhook_label (the full URL stays visible on the admin Delivery Log page). This API is intended for server-to-server use — CORS headers permit browser requests from any origin for flexibility, but never embed the key in public frontend JavaScript, where any visitor could read it.

Developer API

Server-side custom events

spa_track_event('purchase', [
    'page_url'      => home_url('/checkout/'),
    'element_label' => 'Order #1234',
    'event_value'   => '99.00',
]);

Custom event types appear in the dashboard's "Other Events" card, in totals, and in webhook payloads under their own type key.

Custom frontend conversions

Any JavaScript on the page can record a confirmed conversion (a form_success event with the session's attribution snapshot and a unique conversion id) by dispatching a DOM event:

document.dispatchEvent(new CustomEvent('spa:conversion', {
    detail: { name: 'appointment_booked' }
}));

Use this for goals the built-in form integrations can't see — a booking widget's success callback, a phone-click you consider a conversion, a multi-step funnel's final step.

Filters

Filter Purpose
spa_tracked_event Inspect/modify an event row before it is stored; return false to drop it. Receives (array $row, string $type).
spa_webhook_payload Modify the webhook payload before it is sent. Receives (array $payload, int $startTs, int $endTs).
spa_webhook_report_limit Maximum rows per top_* list in the webhook payload (default 200). Receives (int $limit).
spa_allowed_hosts Hostnames accepted in tracked page URLs and Origin/Referer checks, and treated as internal in referrer reports (default: this site's own hosts).
spa_client_ip Override the client IP used for rate limiting, e.g. to map a trusted reverse-proxy header.
spa_rate_limits Tune ingestion rate limits: ['per_ip' => 300, 'site_wide' => 3000] events/minute.
spa_source_aliases Extend/override the map that normalizes utm_source values at ingestion (e.g. 'fb' => 'facebook'). Receives and returns array<string, string> keyed by raw lowercase value.
spa_channel Override the marketing channel assigned to an event at ingestion. Receives (string $channel, array $row, string $type) — return any label (≤ 24 chars).
spa_delivery_log_row Inspect/modify a delivery-log row before it is stored (e.g. site-specific redaction); return false to skip logging that attempt.
spa_allow_insecure_webhooks Return true to allow plain-HTTP webhook endpoint URLs to be saved (development setups only; HTTPS is required by default).

Data storage

Events live in a custom table, {$wpdb->prefix}spa_events, indexed by event type and date; webhook delivery attempts live in {$wpdb->prefix}spa_webhook_deliveries. Deactivating the plugin preserves all data (and suspends — rather than discards — pending webhook retries); deleting the plugin from the Plugins screen removes both tables, all options, and cron events completely (see uninstall.php). On multisite, uninstall iterates every site in the network and performs the same per-site cleanup, so a network-activated install leaves nothing behind on any site.

Folder structure

sitepulse-analytics/
├── sitepulse-analytics.php      # Plugin header, PHP 8.1 guard, bootstrap, activation hooks
├── uninstall.php                # Complete cleanup on plugin deletion
├── README.md
├── assets/
│   ├── css/admin.css            # Shared settings, about & delivery log styles
│   ├── css/dashboard.css        # Dashboard-only styles, incl. the print layout
│   ├── js/admin.js              # Settings page webhook endpoint repeater
│   ├── js/dashboard.js          # Dashboard page (chart navigation & tooltips, panel state, print prep)
│   ├── js/delivery-log.js       # Delivery Log page (accordions, pagination, API card)
│   └── js/tracker.js            # Frontend interaction tracker
└── src/
    ├── Autoloader.php           # Minimal PSR-4 autoloader (no Composer)
    ├── Plugin.php               # Composition root — wires all subsystems
    ├── Admin/
    │   ├── AboutPage.php        # In-admin plugin documentation page
    │   ├── DashboardPage.php    # Analytics dashboard UI
    │   ├── DeliveryLogPage.php  # Webhook delivery log UI (lists, exports, API card)
    │   └── SettingsPage.php     # Settings UI, test send
    ├── Api/
    │   └── DeliveryLogApi.php   # Read-only deliveries REST endpoint + API key
    ├── Database/
    │   ├── DatabaseManager.php  # Events table schema, inserts, retention cleanup
    │   └── Reports.php          # Aggregate queries (dashboard + webhooks)
    ├── Settings/
    │   └── Options.php          # Typed settings access with defaults
    ├── Tracking/
    │   ├── Channels.php         # Source normalization + marketing-channel classification
    │   ├── RestController.php   # Public REST endpoint for event collection
    │   └── ScriptLoader.php     # Enqueues tracker.js with its config
    ├── Updates/
    │   └── GitHubUpdater.php    # Update checks against GitHub releases
    └── Webhook/
        ├── DeliveryLog.php       # Delivery log table, queries, retention
        └── WebhookDispatcher.php # Scheduled JSON delivery to endpoints

Updates

The plugin checks the GitHub repository's latest release every 12 hours through WordPress's normal update pipeline. Publishing a release with a tag like v1.1.0 makes the update banner appear on the Plugins screen; a Check for updates row action forces an immediate check. After an update installs, the plugin folder is automatically normalized back to sitepulse-analytics/ before WordPress reactivates it.

Changelog

1.8.0

Correctness, reporting-accuracy, and reliability fixes following a further external code review. The webhook JSON payload format/schema is unchanged except one approved additive change: utm_medium and utm_id are now included in analytics.top_campaign_content[].

  • Campaign Terms & Content gains utm_medium/utm_id: both the dashboard table and the webhook's top_campaign_content[] entries now include these two fields alongside the existing four, so a keyword/creative combination sharing everything but medium or campaign ID no longer merges into one row.
  • Conversion-only campaigns no longer invisible: the Campaigns report (dashboard and webhook) now surfaces a campaign that converted in-window but has no matching pageview in that same window — previously it was silently dropped from the list entirely, with no indication it existed. Ranking ties (equal view counts) now break deterministically.
  • Webhook totals.form_success now matches the dashboard: both now report the same deduplicated-by-conversion-id count; the webhook payload previously used a raw, potentially at-least-once-inflated row count that the dashboard had already independently corrected.
  • Mid-session Direct attribution fixed: the second and later pageviews, clicks, hovers, and scroll events in an untagged session that landed with no referrer now correctly classify as "Direct" — previously only that session's very first pageview did, and everything after it fell through with a blank channel, excluded from every report.
  • Channel classification recognizes utm_id/utm_term/utm_content-only tags: a visit tagged only through one of these three fields (no source/medium/campaign) is now classified the same way the Campaigns report already treats it, instead of falling through to referrer/Direct logic.
  • Retention-vs-period mismatch now fully consistent: selecting a longer period (e.g. 90 days) than the configured retention window (e.g. 14 days) now clamps the actual query — not just the displayed label — s

This README is longer than the copy stored here. Read the rest on GitHub →

Releases

9 releases.

Tag
Published
v1.8.0 latest
Jul 22, 2026 3mo ago
Jul 17, 2026 3mo ago
Jul 17, 2026 3mo ago
Jul 16, 2026 3mo ago
Jul 12, 2026 3mo ago
Jul 12, 2026 3mo ago
Jul 11, 2026 3mo ago
Jul 11, 2026 3mo ago
Jul 11, 2026 3mo ago

These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.

A grade

Security

SitePulse Analytics 1.8.0 · audited by WP Registry

No security issues found.

Audited release
1.8.0
Findings
none
Worst severity
—
Content hash
169b8546b8523c7bad20b9a2…

WP Registry hashes the installable build and reports on that exact bytes-for-bytes copy. Embargoed findings are withheld until they are disclosed, so a clean verdict means nothing public is outstanding. WP Manifest does not audit code itself.