Skip to main content

WordPress Analytics Plugin Development: The Complete Best Practices Guide

Standard WordPress plugin documentation leaves critical gaps when building analytics plugins. This comprehensive guide addresses what's typically missing—from privacy-compliant tracking architectures to high-performance database patterns that handle millions of pageviews without degrading site speed. Drawing from teardowns of WP Statistics, Independent Analytics, and Koko Analytics, plus industry standards from GA4 and Matomo, this report provides actionable patterns for building production-ready WordPress statistics plugins.


Privacy-first architecture is non-negotiable for analytics plugins

The most significant gap in WordPress plugin documentation is privacy compliance for analytics. GDPR Article 6 requires a lawful basis before processing personal data, and IP addresses are explicitly classified as personal data under Recital 30. Most analytics plugins fail to properly implement these requirements.

Cookieless tracking has become the standard approach. WP Statistics (since v15), Independent Analytics, and Koko Analytics all offer cookieless modes that eliminate consent banner requirements for anonymous statistics. The ePrivacy Directive allows analytics without consent only when: the purpose is strictly limited to audience measurement, data is first-party only, produces anonymous statistical output, involves no cross-referencing, and no third-party transmission occurs.

IP anonymization requires more than truncation. Multiple EU DPAs (French CNIL, Italian Garante, Austrian DPA) have ruled that truncated IP addresses remain personal data. The compliant approaches are:

MethodCompliance LevelImplementation
No storageBestLookup geolocation, discard IP immediately
Hash + daily rotating saltGoodhash('sha256', $ip . $daily_salt)
Last octet zeroingPseudonymization onlyStill personal data under GDPR
Simple hashingNot compliantReversible via rainbow tables in minutes

WordPress Privacy API integration is essential but rarely documented. Analytics plugins must implement personal data exporters and erasers:

// Register data exporter for GDPR compliance
add_filter('wp_privacy_personal_data_exporters', function($exporters) {
$exporters['my-analytics'] = [
'exporter_friendly_name' => 'My Analytics Plugin',
'callback' => 'my_analytics_export_personal_data',
];
return $exporters;
});

// Register data eraser for "right to be forgotten"
add_filter('wp_privacy_personal_data_erasers', function($erasers) {
$erasers['my-analytics'] = [
'eraser_friendly_name' => 'My Analytics Plugin',
'callback' => 'my_analytics_erase_personal_data',
];
return $erasers;
});

WP Consent API integration connects your plugin with consent management platforms like Complianz and CookieYes. Declare compatibility and check consent status before tracking:

// Check consent before tracking
if (function_exists('wp_has_consent') && !wp_has_consent('statistics-anonymous')) {
return; // Don't track without consent in opt-in regions
}

Competitor architecture reveals three distinct approaches to performance

WP Statistics: comprehensive but database-heavy

WP Statistics creates 9 custom database tables with a relational architecture that struggles at scale. The core tables include wp_statistics_visitor (individual visits), wp_statistics_pages (pageview counts), wp_statistics_visitor_relationships (junction table linking visitors to pages), and wp_statistics_visit (daily aggregates).

Performance bottlenecks emerge around 100k monthly pageviews. Users report query execution times of 10-16 seconds on sites with 730,000+ visitor rows and 4 million relationship rows. Root causes include large JOINs across multiple tables, MyISAM engine on some tables, and date range queries scanning massive datasets.

WP Statistics now uses client-side JavaScript tracking via REST API endpoint (wp-json/wp-statistics/v2/hit) as the recommended method, having deprecated server-side PHP tracking due to page caching conflicts. The plugin uses MaxMind GeoLite2 via jsDelivr CDN for geolocation, eliminating the need for users to manage API keys.

Koko Analytics: the buffer file innovation

Koko Analytics achieves 15,000+ requests per second through its unique buffer file architecture—the most distinctive technical approach among WordPress analytics plugins:

  1. Pageviews write to an append-only buffer file in wp-content/uploads/koko-analytics/
  2. Background cron process runs every 60 seconds to aggregate buffer data into permanent database storage
  3. Optimized tracking endpoint bypasses WordPress entirely—a custom PHP file handles incoming requests without bootstrapping WordPress

The database schema is radically minimal: one row per page per day instead of one row per pageview. This results in approximately 10MB for sites with millions of visitors, compared to 200-300MB per million sessions for traditional approaches.

-- Koko Analytics aggregation model
CREATE TABLE wp_koko_analytics_post_stats (
id BIGINT UNSIGNED AUTO_INCREMENT,
post_id INT UNSIGNED NOT NULL,
date DATE NOT NULL,
visitors INT UNSIGNED DEFAULT 0,
pageviews INT UNSIGNED DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY date_post (date, post_id)
);

Independent Analytics: WordPress integration depth

Independent Analytics prioritizes deep WordPress integration over raw performance. Its tracking script recognizes page titles, authors, categories, and custom post types—not just URLs. The plugin stores session-level data enabling bounce rate and time-on-page calculations that Koko Analytics deliberately omits.

The dashboard can slow on sites with 1+ million monthly visitors, but the richer data model enables reports that content-focused sites prefer over pure pageview counts.


High-performance database patterns that most documentation ignores

Custom table design with proper indexing

Never use wp_postmeta for analytics data. The Entity-Attribute-Value model causes exponential query degradation. Custom tables with composite indexes show 150% query time reduction on 1M row datasets.

CREATE TABLE {$wpdb->prefix}analytics_hits (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
page_id BIGINT UNSIGNED NOT NULL,
visitor_hash VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL,
referrer VARCHAR(512) DEFAULT NULL,
country_code CHAR(2) DEFAULT NULL,
PRIMARY KEY (id),
INDEX idx_page_date (page_id, created_at),
INDEX idx_created_at (created_at),
INDEX idx_visitor (visitor_hash, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Index the datetime column last in composite indexes when you're already pruning by time via partitioning. The pattern INDEX (dimension_column, datetime_column) optimizes queries like "pageviews by page for date range."

Time-series partitioning for instant data purging

MySQL RANGE partitioning enables DROP PARTITION for instant deletion of old data versus slow DELETE operations:

PARTITION BY RANGE COLUMNS(created_at) (
PARTITION p_2025_q1 VALUES LESS THAN ('2025-04-01'),
PARTITION p_2025_q2 VALUES LESS THAN ('2025-07-01'),
PARTITION p_future VALUES LESS THAN (MAXVALUE)
);

The partition key must be included in PRIMARY KEY and all UNIQUE keys. Keep partitions under 50 per table for optimal performance.

Roll-up tables eliminate dashboard slowdowns

Pre-aggregate data into summary tables for reporting queries:

// Hourly aggregation via Action Scheduler
$wpdb->query("
INSERT INTO {$wpdb->prefix}analytics_daily_summary
(stat_date, page_id, total_views, unique_visitors)
SELECT DATE(created_at), page_id, COUNT(*), COUNT(DISTINCT visitor_hash)
FROM {$wpdb->prefix}analytics_hits
WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY
GROUP BY DATE(created_at), page_id
ON DUPLICATE KEY UPDATE
total_views = VALUES(total_views),
unique_visitors = VALUES(unique_visitors)
");

Action Scheduler outperforms WP-Cron for analytics processing

Action Scheduler processes 10,000+ actions per hour with built-in logging, failure retry, and concurrent queue support. Use it for batch inserts, data aggregation, and cleanup jobs:

// Queue async action for background processing
as_enqueue_async_action(
'process_pageview_batch',
['batch_id' => $batch_id],
'analytics-plugin'
);

// Schedule recurring aggregation
as_schedule_recurring_action(
time(),
HOUR_IN_SECONDS,
'hourly_analytics_aggregation',
[],
'analytics-plugin'
);

Bulk insert pattern: 50,000 records in 3 seconds

Individual $wpdb->insert() calls in a loop take 3+ minutes for 50,000 records. Bulk insert with prepared statements completes in under 3 seconds:

function bulk_insert_analytics($data_array) {
global $wpdb;
$values = [];
$placeholders = [];

foreach ($data_array as $row) {
$placeholders[] = "(%d, %s, %s)";
$values[] = $row['page_id'];
$values[] = $row['visitor_hash'];
$values[] = $row['created_at'];
}

$query = "INSERT INTO {$wpdb->prefix}analytics_hits
(page_id, visitor_hash, created_at) VALUES "
. implode(', ', $placeholders);

$wpdb->query($wpdb->prepare($query, $values));
}

Data collection architecture: solving the cached page problem

Page caching breaks server-side tracking—the most critical gap in WordPress documentation for analytics plugins. When WP Super Cache, LiteSpeed Cache, or Cloudflare serve cached pages, PHP hooks like template_redirect never fire.

JavaScript beacon tracking is the only reliable solution

// Inject tracking script that fires on every cached page
add_action('wp_footer', function() {
?>
<script>
(function() {
const data = new FormData();
data.append('url', location.href);
data.append('referrer', document.referrer);
data.append('title', document.title);

navigator.sendBeacon('/wp-json/myanalytics/v1/pageview', data);
})();
</script>
<?php
});

The navigator.sendBeacon() API is non-blocking and survives page navigation—critical for accurate analytics without impacting Core Web Vitals.

REST API endpoint design for tracking beacons

register_rest_route('myanalytics/v1', '/track', [
'methods' => 'POST',
'callback' => function(WP_REST_Request $request) {
// Rate limiting
$ip = get_client_ip();
$rate_key = 'analytics_rate_' . md5($ip);
if (get_transient($rate_key) > 100) {
return new WP_REST_Response(null, 429);
}
set_transient($rate_key, (get_transient($rate_key) ?: 0) + 1, 60);

// Bot detection
if (is_bot_request($request->get_header('user-agent'))) {
return new WP_REST_Response(null, 204); // Accept but don't store
}

// Queue for batch processing
as_enqueue_async_action('process_pageview', [
'page_url' => esc_url_raw($request->get_param('url')),
'visitor_hash' => generate_visitor_hash(),
'timestamp' => time()
]);

return new WP_REST_Response(null, 204);
},
'permission_callback' => '__return_true'
]);

Bot detection requires multiple layers

User-agent pattern matching alone misses sophisticated bots. Implement multi-layer detection:

class BotDetector {
private $bot_patterns = [
'googlebot', 'bingbot', 'gptbot', 'claudebot', 'perplexitybot',
'ahrefsbot', 'semrushbot', 'bot', 'crawler', 'spider', 'curl', 'wget'
];

public function is_bot($user_agent) {
$ua = strtolower($user_agent);

// Pattern matching
foreach ($this->bot_patterns as $pattern) {
if (strpos($ua, $pattern) !== false) return true;
}

// Suspicious headers
if (strlen($ua) < 20) return true;
if (empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) return true;

return false;
}
}

Session tracking without cookies

Generate visitor hashes using server-side data with daily rotation for privacy:

function generate_visitor_hash() {
$components = [
$_SERVER['HTTP_USER_AGENT'] ?? '',
$_SERVER['REMOTE_ADDR'] ?? '',
$_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? ''
];

$daily_salt = wp_salt('logged_in') . date('Y-m-d');
return substr(hash('sha256', implode('|', $components) . $daily_salt), 0, 16);
}

Dashboard implementation patterns for WordPress admin

Chart.js is the standard for WordPress analytics

Chart.js (~65KB) works well with WordPress admin styles and is used by WP Statistics and most popular analytics plugins. Proper enqueueing pattern:

add_action('admin_enqueue_scripts', function($hook) {
if ($hook !== 'toplevel_page_my-analytics') return;

wp_enqueue_script('chartjs',
'https://cdn.jsdelivr.net/npm/chart.js', [], null, true);

wp_enqueue_script('my-analytics-dashboard',
plugins_url('js/dashboard.js', __FILE__),
['chartjs', 'jquery'], '1.0', true);

wp_localize_script('my-analytics-dashboard', 'dashboardData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('dashboard_nonce')
]);
});

WP_List_Table for tabular analytics data

Extend WP_List_Table for sortable, paginated analytics reports that match WordPress admin styling:

class Analytics_List_Table extends WP_List_Table {
public function get_columns() {
return [
'page' => __('Page'),
'views' => __('Views'),
'visitors' => __('Unique Visitors'),
'bounce' => __('Bounce Rate')
];
}

public function get_sortable_columns() {
return [
'views' => ['views', true],
'visitors' => ['visitors', true]
];
}

public function prepare_items() {
$per_page = 20;
$this->items = $this->get_analytics_data($per_page, $this->get_pagenum());
$this->set_pagination_args([
'total_items' => $this->get_total_items(),
'per_page' => $per_page
]);
}
}

Cache dashboard queries aggressively

Analytics dashboards should use transients with 5-15 minute TTL:

function get_dashboard_stats($period = 'week') {
$cache_key = 'analytics_stats_' . $period;
$stats = get_transient($cache_key);

if (false === $stats) {
$stats = compute_expensive_analytics_query($period);
set_transient($cache_key, $stats, 5 * MINUTE_IN_SECONDS);
}

return $stats;
}

Geolocation implementation: MaxMind vs alternatives

GeoIP database comparison

DatabaseSizeAccuracyCostUpdate Frequency
MaxMind GeoLite2~68MB (City)99% country, 80% cityFreeWeekly
DB-IP Lite~50MB99% country, 75% cityFreeMonthly
IP2Location LITE~30MB98% countryFreeMonthly

WP Statistics uses GeoLite2 via jsDelivr CDN, eliminating API key requirements. Independent Analytics uses DB-IP Lite. Both approaches avoid requiring users to create MaxMind accounts.

Privacy-compliant location storage

Store country code only by default—city-level data requires explicit consent under GDPR:

$visitor_data = [
'country_code' => 'US', // Safe to store
// 'city' => 'Minneapolis', // Requires consent
// Never store coordinates for analytics
];

Auto-update GeoIP databases via WP-Cron

function schedule_geoip_updates() {
if (!wp_next_scheduled('analytics_geoip_update')) {
wp_schedule_event(time(), 'weekly', 'analytics_geoip_update');
}
}

add_action('analytics_geoip_update', function() {
$db_url = 'https://cdn.jsdelivr.net/npm/geolite2-city/GeoLite2-City.mmdb.gz';
$upload_dir = wp_upload_dir();
$db_path = $upload_dir['basedir'] . '/my-analytics/GeoLite2-City.mmdb';

// Download and extract
$temp = download_url($db_url);
// ... extraction logic
});

WordPress-specific development patterns

dbDelta formatting is notoriously finicky

Critical rules that trip up most developers:

$sql = "CREATE TABLE {$wpdb->prefix}analytics_data (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
visitor_hash varchar(64) NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY visitor_hash (visitor_hash),
KEY created_at (created_at)
) {$wpdb->get_charset_collate()};";

require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
  • Two spaces between PRIMARY KEY and (id) (dbDelta regex requirement)
  • Use KEY not INDEX for secondary indexes
  • No IF NOT EXISTS—dbDelta handles this internally
  • No backticks around identifiers
  • VARCHAR indexes limited to 191 characters for utf8mb4 compatibility

Schema versioning pattern

define('MY_ANALYTICS_DB_VERSION', '2.0');

add_action('plugins_loaded', function() {
$installed = get_option('my_analytics_db_version');
if ($installed != MY_ANALYTICS_DB_VERSION) {
my_analytics_upgrade_db($installed);
update_option('my_analytics_db_version', MY_ANALYTICS_DB_VERSION);
}
});

dbDelta adds columns but never removes them—use explicit ALTER TABLE for column deletions or type changes.

Multisite requires per-site tables

function my_analytics_network_activate($network_wide) {
if (is_multisite() && $network_wide) {
$blog_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs");
foreach ($blog_ids as $blog_id) {
switch_to_blog($blog_id);
my_analytics_create_tables();
restore_current_blog();
}
}
}

Caching plugin compatibility

Use JavaScript/REST tracking exclusively—server-side PHP tracking fails with page caching. Add no-cache headers to tracking endpoints:

add_filter('rest_post_dispatch', function($response, $server, $request) {
if (strpos($request->get_route(), 'myanalytics') !== false) {
$response->header('Cache-Control', 'no-store, no-cache, must-revalidate');
}
return $response;
}, 10, 3);

Industry standards and WordPress.org compliance

GA4 metrics definitions as reference standard

MetricDefinition
SessionsPeriod of interaction; times out after 30 mins inactivity
Engaged Sessions>10 seconds OR conversion event OR ≥2 pageviews
Bounce RateSessions <10 seconds, no conversion, single pageview
UsersUnique visitors with engaged sessions
Engagement RateEngaged sessions / Total sessions

WordPress.org plugin requirements for analytics

Guideline 7 requires explicit user consent for tracking. Documentation must explain what data is collected, how it's used, and include a privacy policy.

Required readme disclosure:

== Description ==
This plugin collects: page URLs visited, anonymous visitor identifiers,
browser/device information. All data stored locally in WordPress database.
No data sent to external servers.

== Privacy Policy ==
[Link to detailed privacy policy]

Security capabilities pattern

// Register custom capabilities
function my_analytics_add_capabilities() {
$admin = get_role('administrator');
$admin->add_cap('view_analytics');
$admin->add_cap('manage_analytics');
$admin->add_cap('delete_analytics_data');

$editor = get_role('editor');
$editor->add_cap('view_analytics'); // Read-only
}

// Check capabilities on admin pages
add_menu_page(
'Analytics',
'Analytics',
'view_analytics', // Required capability
'my-analytics',
'render_analytics_dashboard'
);

Conclusion: architectural decisions that define plugin quality

Building a WordPress analytics plugin requires navigating tensions between data richness and performance, privacy compliance and functionality, simplicity and extensibility. The research reveals these critical architectural choices:

Choose your aggregation model deliberately. Koko Analytics' one-row-per-page-per-day approach handles 15,000+ requests/second but sacrifices session-level metrics. WP Statistics' relational model enables richer reports but struggles past 100k monthly pageviews. Independent Analytics balances with session-based storage that maintains WordPress integration depth.

Privacy-by-design is architecturally simpler. Cookieless tracking with daily-rotating hashes eliminates consent management complexity while remaining GDPR-compliant. Store country codes only, implement WordPress Privacy API hooks, and integrate with WP Consent API.

JavaScript beacon tracking is mandatory. Server-side tracking breaks with any page caching. Use navigator.sendBeacon() for reliable, non-blocking data collection that survives page navigation.

Invest in background processing infrastructure. Action Scheduler for batch inserts, roll-up tables for reporting queries, and aggressive transient caching for dashboards transform a slow plugin into a performant one. The difference between 3-second and 3-minute bulk inserts comes from understanding $wpdb->prepare() with bulk values.

dbDelta's quirks require precise formatting. Two spaces before PRIMARY KEY, KEY instead of INDEX, 191-character VARCHAR limits for utf8mb4 indexes. These undocumented requirements cause most custom table creation failures.

The plugins that succeed—WP Statistics with 600,000+ installations, Koko Analytics with its performance reputation, Independent Analytics with its WordPress-native feel—each made deliberate architectural tradeoffs. Understanding these tradeoffs through their code patterns provides the foundation for building analytics plugins that serve users well at any scale.