├── .browserslistrc ├── assets └── src │ ├── img │ ├── favicon.ico │ ├── icon-192.png │ ├── icon-512.png │ ├── icon-128x128.png │ ├── icon-256x256.png │ ├── screenshot-1.png │ ├── screenshot-2.png │ ├── screenshot-3.png │ ├── screenshot-4.png │ ├── screenshot-5.png │ ├── screenshot-6.png │ ├── banner-1544x500.png │ ├── banner-772x250.png │ ├── apple-touch-icon.png │ ├── screenshot-1-830x447.png │ └── icon.svg │ ├── js │ ├── sw.js │ ├── koko-analytics-script-test.js │ ├── dashboard-widget.js │ ├── query-loop-block.js │ ├── script.js │ ├── dashboard.js │ └── imports │ │ └── chart.js │ ├── manifest.json │ └── github │ ├── lighthouse_performance.svg │ └── lighthouse_accessibility.svg ├── code-snippets ├── disable-custom-endpoint.php ├── modify-dashboard-widget │ ├── img.png │ └── README.md ├── add-domains-to-referrer-blocklist.php ├── event-snippets │ ├── track-form-submits.js │ ├── track-browser-language.js │ ├── track-gravity-form-submits.js │ ├── track-outbound-link-clicks.js │ ├── track-contact-form-7-submits.js │ ├── track-404-not-found.php │ ├── track-screen-width.js │ ├── bebop-theme-track-plays.js │ ├── track-mailto-or-tel-link-clicks.js │ ├── track-ninja-forms-submit.js │ ├── track-time-to-interactive.js │ ├── README.md │ ├── track-utm-parameters.js │ ├── track-utm-parameters-all.js │ ├── track-utm-parameters.php │ └── track-utm-parameters-all-combined.js ├── disable-tracking-for-404s.php ├── allow-editor-role.php ├── remove-pageviews-from-admin-bar.php ├── filter-default-option-value.php ├── disable-tracking-on-certain-urls.php ├── filter-pageviews-column-days-setting.php ├── change-chart-colors.php ├── complianz-use-cookie-after-consent.js ├── track-pageview-from-single-page-app.js ├── disable-tracking-for-certain-ip-addresses.php ├── ignore-tracking-for-certain-user-agents.php ├── ignore-some-referrer-traffic-using-regex.php ├── consolidate-referrer-urls.php ├── use-different-custom-endpoint.php └── README.md ├── .gitignore ├── tests ├── bootstrap.php ├── StatsTest.php ├── PrunerTest.php ├── Admin │ └── AdminTest.php ├── AggregatorTest.php ├── ScriptLoaderTest.php ├── DashboardWidgetTest.php ├── PluginTest.php ├── EndpointInstallerTest.php ├── BlocklistTest.php ├── benchmarks │ ├── functions.php │ ├── join-vs-str-repeat.php │ ├── read-dir.php │ ├── return-bool-vs-return-void.php │ ├── static-method-vs-instance-method.php │ ├── plugin.php │ ├── read-line.php │ ├── run-benchmark.php │ └── parse-url.php ├── MigrationsTest.php ├── FmtTest.php ├── RestTest.php ├── mocks.php ├── FunctionsTest.php └── Normalizers │ └── NormalizerTest.php ├── bin ├── update-referrer-blocklist ├── update-external-strings ├── check-php-syntax └── create-package ├── eslint.config.mjs ├── data └── .htaccess ├── migrations ├── 2.0.20-drop-temporary-post-stats-table.php ├── 1.0.4-fix-referrer-id-column.php ├── 1.8.1-verify-optimized-endpoint.php ├── 1.8.5-update-optimized-endpoint.php ├── 2.0.13-fix-post-id-column-type.php ├── 1.7.0-protect-uploads-dir.php ├── 1.3.12-delete-invalid-referrers.php ├── 1.9.992-maybe-migrate-post-stats.php ├── 2.0.12-fix-incorrect-post-paths.php ├── 1.0.6-ensure-referrer-urls-table-exists.php ├── 1.9.993-maybe-migrate-referrer-stats.php ├── 1.0.1-change-table-charsets.php ├── 2.0.11-set-paths-table-collation.php ├── 1.0.8-fix-referrer-id-column-again.php ├── 1.8.0-migrate-to-new-tracking-method-setting.php ├── 1.6.3-schedule-aggregate-event.php ├── 1.1.1-create-dates-table.php ├── 1.9.991-prepare-post-stats-table.php ├── 1.0.2-change-column-types-add-cap-to-administrator.php └── 1.0.0-initial-schema.php ├── src ├── collect-functions.php ├── class-aggregator.php ├── class-pageview-aggregator.php ├── Normalizers │ ├── Normalizer.php │ ├── Path.php │ └── Referrer.php ├── Dashboard_Standalone.php ├── Resources │ ├── views │ │ ├── nav.php │ │ ├── settings │ │ │ ├── events.php │ │ │ ├── email-reports.php │ │ │ ├── performance.php │ │ │ ├── dashboard.php │ │ │ ├── help.php │ │ │ ├── plausible_importer.php │ │ │ ├── data.php │ │ │ ├── tracking.php │ │ │ └── jetpack_importer.php │ │ ├── standalone.php │ │ ├── dashboard-widget.php │ │ └── settings-page.php │ ├── backwards-compat.php │ └── functions │ │ └── global.php ├── Admin │ ├── Bar.php │ ├── Data_Reset.php │ ├── Data_Import.php │ └── Pages.php ├── Fmt.php ├── Blocklist.php ├── Actions.php ├── Path_Repository.php ├── Referrer_Repository.php ├── Plugin.php ├── Router.php ├── Command.php ├── Query_Loop_Block.php ├── Dashboard_Widget.php ├── Shortcodes │ ├── Shortcode_Most_Viewed_Posts.php │ └── Shortcode_Site_Counter.php ├── Fingerprinter.php ├── Migrations.php ├── Import │ ├── Importer.php │ └── Plausible_Importer.php ├── Aggregator.php ├── Notice_Pro.php ├── Pruner.php ├── Script_Loader.php └── Chart_View.php ├── .editorconfig ├── SECURITY.md ├── autoload.php ├── .github └── workflows │ ├── check-php-syntax.yml │ └── build-test-lint.yml ├── phpcs.xml ├── composer.json ├── phpunit.xml.dist ├── webpack.config.js ├── uninstall.php ├── package.json └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | # Browsers that we support 2 | defaults 3 | -------------------------------------------------------------------------------- /assets/src/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibericode/koko-analytics/main/assets/src/img/favicon.ico -------------------------------------------------------------------------------- /code-snippets/disable-custom-endpoint.php: -------------------------------------------------------------------------------- 1 | { 2 | event.respondWith( 3 | fetch(event.request) 4 | ); 5 | }); 6 | -------------------------------------------------------------------------------- /code-snippets/modify-dashboard-widget/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ibericode/koko-analytics/main/code-snippets/modify-dashboard-widget/img.png -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 3 | Order Deny,Allow 4 | Deny from all 5 | 6 | 7 | # Apache 2.4 8 | 9 | Require all denied 10 | 11 | -------------------------------------------------------------------------------- /code-snippets/add-domains-to-referrer-blocklist.php: -------------------------------------------------------------------------------- 1 | query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_post_stats_old"); 9 | -------------------------------------------------------------------------------- /code-snippets/disable-tracking-for-404s.php: -------------------------------------------------------------------------------- 1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT"); 9 | -------------------------------------------------------------------------------- /migrations/1.8.1-verify-optimized-endpoint.php: -------------------------------------------------------------------------------- 1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY COLUMN post_id INT UNSIGNED NOT NULL DEFAULT 0"); 9 | -------------------------------------------------------------------------------- /code-snippets/allow-editor-role.php: -------------------------------------------------------------------------------- 1 | has_cap('view_koko_analytics')) { 7 | $role->add_cap('view_koko_analytics'); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /code-snippets/remove-pageviews-from-admin-bar.php: -------------------------------------------------------------------------------- 1 | src/Resources/external-strings.php 6 | ./vendor/bin/phpcbf src/Resources/external-strings.php || true 7 | 8 | git add src/Resources/external-strings.php || true 9 | git commit -m "Update external strings from Koko Analytics Pro" 10 | -------------------------------------------------------------------------------- /migrations/1.7.0-protect-uploads-dir.php: -------------------------------------------------------------------------------- 1 | query($wpdb->prepare("DELETE s, u FROM {$wpdb->prefix}koko_analytics_referrer_stats s LEFT JOIN {$wpdb->prefix}koko_analytics_referrer_urls u ON s.id = u.id WHERE u.url LIKE %s;", [$site_url . '%'])); 11 | -------------------------------------------------------------------------------- /src/Dashboard_Standalone.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | -------------------------------------------------------------------------------- /tests/ScriptLoaderTest.php: -------------------------------------------------------------------------------- 1 | get_var("SELECT COUNT(*) FROM {$wpdb->prefix}koko_analytics_post_stats"); 11 | if ($count && $count < 25000 && method_exists(Actions::class, 'migrate_post_stats_to_v2')) { 12 | Actions::migrate_post_stats_to_v2(); 13 | } 14 | -------------------------------------------------------------------------------- /migrations/2.0.12-fix-incorrect-post-paths.php: -------------------------------------------------------------------------------- 1 | get_var("SELECT COUNT(*) FROM {$wpdb->prefix}koko_analytics_post_stats"); 11 | if ($count && $count < 25000 && method_exists(Actions::class, 'fix_post_paths_after_v2')) { 12 | Actions::fix_post_paths_after_v2(); 13 | } 14 | -------------------------------------------------------------------------------- /migrations/1.0.6-ensure-referrer-urls-table-exists.php: -------------------------------------------------------------------------------- 1 | query( 9 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_referrer_urls ( 10 | id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 11 | url VARCHAR(255) NOT NULL, 12 | UNIQUE INDEX (url) 13 | ) ENGINE=INNODB CHARACTER SET=ascii" 14 | ); 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; top-most EditorConfig file 2 | root = true 3 | 4 | ; Unix-style newlines 5 | [*] 6 | charset = utf-8 7 | end_of_line = LF 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.{php,html}] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{js,css}] 16 | indent_style = space 17 | indent_size = 2 18 | 19 | [*.md] 20 | max_line_length = 80 21 | 22 | [COMMIT_EDITMSG] 23 | max_line_length = 0 -------------------------------------------------------------------------------- /tests/DashboardWidgetTest.php: -------------------------------------------------------------------------------- 1 | -1) { 8 | return; 9 | } 10 | 11 | window.koko_analytics.trackEvent('Outbound click', href); 12 | }); 13 | -------------------------------------------------------------------------------- /src/Resources/backwards-compat.php: -------------------------------------------------------------------------------- 1 | 9 | 19 | Integrations > Script Center. 8 | -------------------------------------------------------------------------------- /code-snippets/track-pageview-from-single-page-app.js: -------------------------------------------------------------------------------- 1 | let postId = -1 // general site visit 2 | 3 | // if this is a visit to a specific page or post, we need to supply the post ID to trackPageview 4 | // so we extract if from the element 5 | const matches = document.body.className.match(/(postid-|page-id-)(\d+)/) 6 | if (matches && matches.length === 3) { 7 | postId = matches.pop() 8 | } 9 | 10 | window.koko_analytics.trackPageview(postId) 11 | -------------------------------------------------------------------------------- /migrations/1.9.993-maybe-migrate-referrer-stats.php: -------------------------------------------------------------------------------- 1 | get_var("SELECT COUNT(*) FROM {$wpdb->prefix}koko_analytics_referrer_stats"); 11 | if ($count && $count < 25000 && method_exists(Actions::class, 'migrate_referrer_stats_to_v2')) { 12 | Actions::migrate_referrer_stats_to_v2(); 13 | } 14 | -------------------------------------------------------------------------------- /tests/EndpointInstallerTest.php: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 |

Custom events

6 |

Custom events is a feature from Koko Analytics Pro that allows you to track any type of event that occurs on your site, like outbound link clicks or form submissions.

7 |

Upgrade to Koko Analytics Pro

8 | 9 | 10 | -------------------------------------------------------------------------------- /tests/BlocklistTest.php: -------------------------------------------------------------------------------- 1 | contains('')); 16 | self::assertFalse($b->contains('')); 17 | self::assertTrue($b->contains('1xslot.site')); 18 | self::assertFalse($b->contains('kokoanalytics.com')); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /assets/src/js/query-loop-block.js: -------------------------------------------------------------------------------- 1 | const MY_VARIATION_NAME = 'koko-analytics/most-viewed-pages'; 2 | 3 | window.wp.blocks.registerBlockVariation( 'core/query', { 4 | apiVersion: 3, 5 | name: MY_VARIATION_NAME, 6 | title: 'Most Viewed Post Type', 7 | description: 'Displays a list of your most viewed posts, pages or other post types.', 8 | isActive: [ 'namespace' ], 9 | icon: '', 10 | attributes: { 11 | namespace: MY_VARIATION_NAME, 12 | }, 13 | scope: [ 'inserter' ], 14 | allowedControls: [ 'postType'], 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /migrations/1.0.1-change-table-charsets.php: -------------------------------------------------------------------------------- 1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_site_stats CONVERT TO CHARACTER SET ascii;"); 9 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats CONVERT TO CHARACTER SET ascii;"); 10 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats CONVERT TO CHARACTER SET ascii;"); 11 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls CONVERT TO CHARACTER SET ascii;"); 12 | -------------------------------------------------------------------------------- /code-snippets/event-snippets/track-screen-width.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('load', function() { 2 | function bucket(n) { 3 | if (n < 500) { 4 | return '0 - 500'; 5 | } else if (n < 1000) { 6 | return '500 - 1000'; 7 | } else if (n < 1500) { 8 | return '1000 - 1500'; 9 | } else if (n < 2000) { 10 | return '1500 - 2000'; 11 | } else { 12 | return '2000+'; 13 | } 14 | } 15 | 16 | window.koko_analytics.trackEvent('Screen width', bucket(screen.width)); 17 | }); 18 | -------------------------------------------------------------------------------- /migrations/2.0.11-set-paths-table-collation.php: -------------------------------------------------------------------------------- 1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_paths CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci;"); 12 | -------------------------------------------------------------------------------- /code-snippets/ignore-some-referrer-traffic-using-regex.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /migrations/1.0.8-fix-referrer-id-column-again.php: -------------------------------------------------------------------------------- 1 | get_var("SELECT MAX(id) FROM {$wpdb->prefix}koko_analytics_referrer_urls"); 11 | $max_id++; 12 | $query = $wpdb->prepare("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls AUTO_INCREMENT = %d, MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT", $max_id); 13 | $wpdb->query($query); 14 | -------------------------------------------------------------------------------- /migrations/1.8.0-migrate-to-new-tracking-method-setting.php: -------------------------------------------------------------------------------- 1 | 60, // 60 seconds 10 | 'display' => esc_html__('Every minute', 'koko-analytics'), 11 | ]; 12 | return $schedules; 13 | }, 10, 1); 14 | 15 | // schedule event 16 | wp_schedule_event(time() + 60, 'koko_analytics_stats_aggregate_interval', 'koko_analytics_aggregate_stats'); 17 | } 18 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | rules 4 | 5 | code-snippets/ 6 | src/ 7 | tests/ 8 | migrations/ 9 | koko-analytics.php 10 | uninstall.php 11 | autoload.php 12 | *\.(html|css|js) 13 | tests/benchmarks/* 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /tests/benchmarks/return-bool-vs-return-void.php: -------------------------------------------------------------------------------- 1 | add_node( 22 | [ 23 | 'parent' => 'site-name', 24 | 'id' => 'koko-analytics', 25 | 'title' => esc_html__('Analytics', 'koko-analytics'), 26 | 'href' => admin_url('/index.php?page=koko-analytics'), 27 | ] 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Resources/views/settings/email-reports.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 |

11 | 12 |

Email reports is a feature from Koko Analytics Pro that allows you to configure periodic email reports, delivering a summary of your most important metrics to your email inbox.

13 |

Upgrade to Koko Analytics Pro

14 | 15 | -------------------------------------------------------------------------------- /assets/src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Koko Analytics", 3 | "short_name": "Koko Analytics", 4 | "description": "Analytics dashboard for your WordPress site. Powered by Koko Analytics.", 5 | "icons": [ 6 | { 7 | "src": "img/icon.svg", 8 | "sizes": "48x48 72x72 96x96 128x128 256x256 512x512", 9 | "type": "image/svg+xml", 10 | "purpose": "any maskable" 11 | }, 12 | { 13 | "src": "img/icon-192.png", 14 | "type": "image/png", 15 | "sizes": "192x192", 16 | "purpose": "maskable" 17 | }, 18 | { 19 | "src": "img/icon-512.png", 20 | "type": "image/png", 21 | "sizes": "512x512", 22 | "purpose": "maskable" 23 | } 24 | ], 25 | "display": "standalone", 26 | "start_url": "/?koko-analytics-dashboard", 27 | "theme_color": "#FFF", 28 | "background_color": "#FFF" 29 | } 30 | -------------------------------------------------------------------------------- /code-snippets/event-snippets/track-utm-parameters-all.js: -------------------------------------------------------------------------------- 1 | // This code snippets monitors the URL for UTM parameters like "utm_source=WordPress.org" and sends it to a Koko Analytics custom event 2 | // In order for this to work, you need to have the custom event created in your Koko Analytics dashboard settings. 3 | 4 | window.addEventListener('load', function() { 5 | let map = { 6 | 'utm_source': 'UTM Source', 7 | 'utm_medium': 'UTM Medium', 8 | 'utm_campaign': 'UTM Campaign', 9 | }; 10 | 11 | let queryParams = new URLSearchParams(window.location.search); 12 | let hashParams = new URLSearchParams(window.location.hash.substring(1)); 13 | for (let [p, eventName] of Object.entries(map)) { 14 | let value = queryParams.get(p) ?? hashParams.get(p); 15 | if (value) { 16 | window.koko_analytics.trackEvent(eventName, value); 17 | } 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /code-snippets/use-different-custom-endpoint.php: -------------------------------------------------------------------------------- 1 | dir)) { 17 | mkdir($this->dir, 0700, true); 18 | } 19 | } 20 | 21 | public function testCanInstantiate() 22 | { 23 | $instance = new Migrations('1.0', '1.1', $this->dir); 24 | $this->assertInstanceOf(Migrations::class, $instance); 25 | } 26 | 27 | public function tearDown(): void 28 | { 29 | array_map('unlink', glob($this->dir . '/*.php')); 30 | if (file_exists($this->dir)) { 31 | rmdir($this->dir); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /code-snippets/event-snippets/track-utm-parameters.php: -------------------------------------------------------------------------------- 1 | 7.4", 21 | "ext-json": "*" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^9.6", 25 | "squizlabs/php_codesniffer": "^3.11" 26 | }, 27 | "scripts": { 28 | "test": "vendor/bin/phpunit", 29 | "check-syntax": "bin/check-php-syntax", 30 | "lint": "vendor/bin/phpcs -n -s", 31 | "fmt": "vendor/bin/phpcbf" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /code-snippets/event-snippets/track-utm-parameters-all-combined.js: -------------------------------------------------------------------------------- 1 | // Event name: UTM Parameters 2 | // This snippet tracks any UTM parameter into a single "UTM Parameters" event using the following format: Source / Medium / Campaign 3 | window.addEventListener('load', function() { 4 | let queryParams = new URLSearchParams(window.location.search); 5 | let hashParams = new URLSearchParams(window.location.hash.substring(1)); 6 | 7 | let source = queryParams.get('utm_source') ?? hashParams.get('utm_source'); 8 | let medium = queryParams.get('utm_medium') ?? hashParams.get('utm_medium'); 9 | let campaign = queryParams.get('utm_campaign') ?? hashParams.get('utm_campaign'); 10 | 11 | if (source || medium || campaign) { 12 | let eventName = 'UTM Parameters'; 13 | let eventValue = [source, medium, campaign].filter(v => !!v).join(' / ') 14 | 15 | window.koko_analytics.trackEvent(eventName, eventValue); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /tests/benchmarks/static-method-vs-instance-method.php: -------------------------------------------------------------------------------- 1 | method(); 25 | }, $iterations); 26 | printf("new instance method \t%.2f ns\t(%.2f per it)\n", $time, $time / $iterations); 27 | 28 | // existing instance 29 | $instance = new A(); 30 | $time = bench(function () use ($instance) { 31 | $instance->method(); 32 | }, $iterations); 33 | printf("instance method \t%.2f ns\t(%.2f per it)\n", $time, $time / $iterations); 34 | 35 | // static method 36 | $time = bench(function () { 37 | B::method(); 38 | }, $iterations); 39 | printf("static method\t\t%.2f ns\t(%.2f per it)\n", $time, $time / $iterations); 40 | -------------------------------------------------------------------------------- /src/Fmt.php: -------------------------------------------------------------------------------- 1 | 0 ? '+' : ''; 14 | $formatted = \number_format_i18n($pct * 100, 0); 15 | return $prefix . $formatted . '%'; 16 | } 17 | 18 | public static function referrer_url_label(string $url): string 19 | { 20 | // if link starts with android-app://, turn that prefix into something more human readable 21 | if (\strpos($url, 'android-app://') === 0) { 22 | return \str_replace('android-app://', 'Android app: ', $url); 23 | } 24 | 25 | // strip protocol and www. prefix 26 | $url = (string) \preg_replace('/^https?:\/\/(?:www\.)?/', '', $url); 27 | 28 | // trim trailing slash 29 | $url = \rtrim($url, '/'); 30 | 31 | return apply_filters('koko_analytics_referrer_url_label', $url); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const CopyPlugin = require("copy-webpack-plugin"); 3 | 4 | module.exports = { 5 | entry: { 6 | dashboard: './assets/src/js/dashboard.js', 7 | 'dashboard-widget': './assets/src/js/dashboard-widget.js', 8 | script: './assets/src/js/script.js', 9 | sw: './assets/src/js/sw.js', 10 | 'koko-analytics-script-test': './assets/src/js/koko-analytics-script-test.js', 11 | 'query-loop-block': './assets/src/js/query-loop-block.js' 12 | }, 13 | output: { 14 | filename: '[name].js', 15 | path: path.resolve(__dirname, 'assets/dist/js') 16 | }, 17 | plugins: [ 18 | new CopyPlugin({ 19 | patterns: [ 20 | { from: './assets/src/img', to: path.resolve(__dirname, './assets/dist/img') }, 21 | { from: './assets/src/css', to: path.resolve(__dirname, './assets/dist/css') }, 22 | { from: './assets/src/manifest.json', to: path.resolve(__dirname, './assets/dist/manifest.json') } 23 | ], 24 | }), 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /bin/check-php-syntax: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getPathname(), './vendor/') === 0 17 | || strpos($file->getPathname(), './node_modules/') === 0 18 | || false == $file->isFile() 19 | || $file->getExtension() !== 'php' 20 | ) { 21 | continue; 22 | } 23 | 24 | $exit_code = 0; 25 | $output = []; 26 | exec("php --define error_reporting=-1 -l {$file->getPathname()}", $output, $exit_code); 27 | $output = join("\n", $output); 28 | echo $output . "\n"; 29 | 30 | if ($exit_code || strpos($output, 'Deprecated') !== false) { 31 | $global_exit_code = 1; 32 | } 33 | } 34 | 35 | exit($global_exit_code); 36 | -------------------------------------------------------------------------------- /assets/src/js/script.js: -------------------------------------------------------------------------------- 1 | // Map variables to global identifiers so that minifier can mangle them to even shorter names 2 | var win = window; 3 | var ka = "koko_analytics"; 4 | 5 | function request(data) { 6 | data['m'] = win[ka].use_cookie ? 'c' : win[ka].method[0]; 7 | navigator.sendBeacon(win[ka].url, new URLSearchParams(data)); 8 | } 9 | 10 | function trackPageview() { 11 | if ( 12 | // do not track if this is a prerender request 13 | (document.visibilityState == 'prerender') || 14 | 15 | // do not track if user agent looks like a bot 16 | ((/bot|crawl|spider|seo|lighthouse|facebookexternalhit|preview/i).test(navigator.userAgent)) 17 | ) { 18 | return; 19 | } 20 | 21 | // don't store referrer if from same-site 22 | var referrer = document.referrer.indexOf(win[ka].site_url) == 0 ? '' : document.referrer; 23 | request({ pa: win[ka].path, po: win[ka].post_id, r: referrer }) 24 | } 25 | 26 | win[ka].request = request; 27 | win[ka].trackPageview = trackPageview; 28 | 29 | win.addEventListener('load', function() { win[ka].trackPageview() }); 30 | -------------------------------------------------------------------------------- /tests/benchmarks/plugin.php: -------------------------------------------------------------------------------- 1 | > 10; 31 | 32 | header("Content-Type: application/json"); 33 | echo json_encode(['memory' => $memory_used, 'time' => $time]); 34 | -------------------------------------------------------------------------------- /uninstall.php: -------------------------------------------------------------------------------- 1 | query("DROP TABLE {$wpdb->prefix}koko_analytics_site_stats;"); 27 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_post_stats;"); 28 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_paths;"); 29 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_referrer_stats;"); 30 | // $wpdb->query("DROP TABLE {$wpdb->prefix}koko_analytics_referrer_urls;"); 31 | -------------------------------------------------------------------------------- /code-snippets/README.md: -------------------------------------------------------------------------------- 1 | # Code snippets for Koko Analytics 2 | 3 | This directory contains a collection of code snippets to modify or extend the default behavior of the [Koko Analytics plugin](https://www.kokoanalytics.com/). 4 | 5 | You can copy these snippets into a custom plugin or in your active theme its `functions.php` file. 6 | Our recommendation is to create a file called `/wp-content/plugins/koko-analytics-modifications.php` with the following file contents: 7 | 8 | Please read [modifying Koko Analytics using filter hooks](https://www.kokoanalytics.com/kb/modifying-koko-analytics-using-filter-hooks/) if you're unusure how to use any of these snippets. 9 | 10 | ```php 11 | query( 9 | "DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_dates" 10 | ); 11 | $wpdb->query( 12 | "CREATE TABLE {$wpdb->prefix}koko_analytics_dates ( 13 | date DATE PRIMARY KEY NOT NULL 14 | ) ENGINE=INNODB CHARACTER SET=ascii" 15 | ); 16 | 17 | $date = new \DateTime('2000-01-01'); 18 | $end = new \DateTime('2100-01-01'); 19 | $values = []; 20 | while ($date < $end) { 21 | $values[] = $date->format('Y-m-d'); 22 | $date->modify('+1 day'); 23 | 24 | if (count($values) === 365) { 25 | $placeholders = rtrim(str_repeat('(%s),', count($values)), ','); 26 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_dates(date) VALUES {$placeholders}", $values)); 27 | $values = []; 28 | } 29 | } 30 | 31 | $placeholders = rtrim(str_repeat('(%s),', count($values)), ','); 32 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_dates(date) VALUES {$placeholders}", $values)); 33 | -------------------------------------------------------------------------------- /tests/FmtTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($fmt->percent(0), ''); 16 | $this->assertEquals($fmt->percent(0.00), ''); 17 | $this->assertEquals($fmt->percent(1.00), '+100%'); 18 | $this->assertEquals($fmt->percent(-1.00), '-100%'); 19 | $this->assertEquals($fmt->percent(0.55), '+55%'); 20 | $this->assertEquals($fmt->percent(-0.55), '-55%'); 21 | } 22 | 23 | public function testGetReferrerUrlLabel(): void 24 | { 25 | $fmt = new Fmt(); 26 | 27 | self::assertEquals('', $fmt->referrer_url_label('')); 28 | self::assertEquals('kokoanalytics.com', $fmt->referrer_url_label('https://www.kokoanalytics.com/')); 29 | self::assertEquals('kokoanalytics.com/about', $fmt->referrer_url_label('https://www.kokoanalytics.com/about')); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "koko-analytics", 3 | "description": "Privacy-friendly analytics for your WordPress site", 4 | "scripts": { 5 | "build": "webpack --mode=production", 6 | "watch": "webpack --mode=development --watch", 7 | "download-referrer-blocklist": "curl https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt -k -o data/referrer-blocklist", 8 | "create-pot": "wp i18n make-pot .", 9 | "lint": "eslint assets/src/js/*.js" 10 | }, 11 | "private": true, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/ibericode/koko-analytics.git" 15 | }, 16 | "keywords": [ 17 | "wordpress" 18 | ], 19 | "author": "Danny van Kooten", 20 | "license": "GPL-3.0-or-later", 21 | "bugs": { 22 | "url": "https://github.com/ibericode/koko-analytics/issues" 23 | }, 24 | "homepage": "https://github.com/ibericode/koko-analytics#readme", 25 | "devDependencies": { 26 | "@eslint/js": "^9.31.0", 27 | "copy-webpack-plugin": "^13.0.0", 28 | "eslint": "^9.31.0", 29 | "globals": "^16.3.0", 30 | "webpack": "^5.100.2", 31 | "webpack-cli": "^6.0.1" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Blocklist.php: -------------------------------------------------------------------------------- 1 | getFilename(); 17 | if (!is_file($filename)) { 18 | return []; 19 | } 20 | 21 | return \file($filename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; 22 | } 23 | 24 | public function contains(string $url): bool 25 | { 26 | if ($url === '') { 27 | return false; 28 | } 29 | 30 | static $list; 31 | if ($list === null) { 32 | $list = $this->read(); 33 | } 34 | 35 | foreach ($list as $domain) { 36 | $domain = trim($domain); 37 | 38 | if ($domain === '') { 39 | continue; 40 | } 41 | 42 | if (str_contains($url, $domain)) { 43 | return true; 44 | } 45 | } 46 | 47 | return false; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Actions.php: -------------------------------------------------------------------------------- 1 | lighthouse performance: 100%lighthouse performance100% -------------------------------------------------------------------------------- /assets/src/github/lighthouse_accessibility.svg: -------------------------------------------------------------------------------- 1 | lighthouse accessibility: 80%lighthouse accessibility80% -------------------------------------------------------------------------------- /tests/RestTest.php: -------------------------------------------------------------------------------- 1 | validate_date_param('2000-01-01', null, null)); 22 | self::assertFalse($rest->validate_date_param('foobar', null, null)); 23 | } 24 | 25 | public function test_sanitize_bool_param() 26 | { 27 | $rest = new Rest(); 28 | self::assertTrue($rest->sanitize_bool_param('true', null, null)); 29 | self::assertTrue($rest->sanitize_bool_param('yes', null, null)); 30 | self::assertTrue($rest->sanitize_bool_param('1', null, null)); 31 | self::assertFalse($rest->sanitize_bool_param('0', null, null)); 32 | self::assertFalse($rest->sanitize_bool_param('no', null, null)); 33 | self::assertFalse($rest->sanitize_bool_param('false', null, null)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /migrations/1.9.991-prepare-post-stats-table.php: -------------------------------------------------------------------------------- 1 | hide_errors(); 9 | 10 | $wpdb->query( 11 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_paths ( 12 | id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 13 | path VARCHAR(2000) NOT NULL, 14 | INDEX (path(191)) 15 | ) ENGINE=INNODB CHARACTER SET=utf8mb4" 16 | ); 17 | 18 | // prepare columns 19 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats CHANGE COLUMN id post_id INT UNSIGNED"); 20 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats ADD COLUMN path_id MEDIUMINT UNSIGNED"); 21 | 22 | $results = $wpdb->get_var("SELECT COUNT(DISTINCT(post_id)) FROM {$wpdb->prefix}koko_analytics_post_stats WHERE post_id IS NOT NULL AND path_id IS NULL"); 23 | 24 | if (!$results) { 25 | // make new path_id column not-nullable 26 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY COLUMN path_id MEDIUMINT UNSIGNED NOT NULL"); 27 | 28 | // change primary key to be on date and path_id column 29 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats DROP PRIMARY KEY, ADD PRIMARY KEY(date, path_id)"); 30 | } 31 | -------------------------------------------------------------------------------- /src/Path_Repository.php: -------------------------------------------------------------------------------- 1 | get_results($wpdb->prepare("SELECT id, path FROM {$wpdb->prefix}koko_analytics_paths p WHERE p.path IN({$placeholders})", $paths)); 16 | 17 | // fill map with path ID's from database 18 | foreach ($results as $r) { 19 | $map[$r->path] = $r->id; 20 | } 21 | 22 | // get all entries without an ID 23 | $new_values = array_keys($map, 0); 24 | 25 | if (count($new_values) > 0) { 26 | $placeholders = rtrim(str_repeat('(%s),', count($new_values)), ','); 27 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_paths(path) VALUES {$placeholders}", $new_values)); 28 | $last_insert_id = $wpdb->insert_id; 29 | 30 | foreach ($new_values as $key) { 31 | $map[$key] = $last_insert_id++; 32 | } 33 | } 34 | 35 | return $map; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /migrations/1.0.2-change-column-types-add-cap-to-administrator.php: -------------------------------------------------------------------------------- 1 | query("ALTER TABLE {$wpdb->prefix}koko_analytics_site_stats MODIFY visitors MEDIUMINT UNSIGNED NOT NULL"); 9 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_site_stats MODIFY pageviews MEDIUMINT UNSIGNED NOT NULL"); 10 | 11 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY visitors MEDIUMINT UNSIGNED NOT NULL"); 12 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_post_stats MODIFY pageviews MEDIUMINT UNSIGNED NOT NULL"); 13 | 14 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats MODIFY id MEDIUMINT UNSIGNED NOT NULL"); 15 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats MODIFY visitors MEDIUMINT UNSIGNED NOT NULL"); 16 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_stats MODIFY pageviews MEDIUMINT UNSIGNED NOT NULL"); 17 | 18 | $wpdb->query("ALTER TABLE {$wpdb->prefix}koko_analytics_referrer_urls MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT"); 19 | 20 | $role = get_role('administrator'); 21 | if ($role) { 22 | $role->add_cap('view_koko_analytics'); 23 | $role->add_cap('manage_koko_analytics'); 24 | } 25 | -------------------------------------------------------------------------------- /src/Referrer_Repository.php: -------------------------------------------------------------------------------- 1 | get_results($wpdb->prepare("SELECT id, url FROM {$wpdb->prefix}koko_analytics_referrer_urls r WHERE r.url IN({$placeholders})", $values)); 16 | 17 | // fill map with normalized ID's from database 18 | foreach ($results as $r) { 19 | $map[$r->url] = $r->id; 20 | } 21 | 22 | // get all entries without an ID 23 | $new_values = array_keys($map, 0); 24 | 25 | if (count($new_values) > 0) { 26 | $placeholders = rtrim(str_repeat('(%s),', count($new_values)), ','); 27 | $wpdb->query($wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_referrer_urls(url) VALUES {$placeholders}", $new_values)); 28 | $last_insert_id = $wpdb->insert_id; 29 | 30 | foreach ($new_values as $key) { 31 | $map[$key] = $last_insert_id++; 32 | } 33 | } 34 | 35 | return $map; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | add_cap('view_koko_analytics'); 19 | $role->add_cap('manage_koko_analytics'); 20 | } 21 | } 22 | 23 | public static function create_and_protect_uploads_dir(): void 24 | { 25 | $filename = get_buffer_filename(); 26 | $directory = \dirname($filename); 27 | if (! \is_dir($directory)) { 28 | \mkdir($directory, 0755, true); 29 | } 30 | 31 | // create empty index.html to prevent directory listing 32 | file_put_contents("$directory/index.html", ''); 33 | 34 | // create .htaccess in case we're using apache 35 | $lines = [ 36 | '', 37 | 'Order deny,allow', 38 | 'Deny from all', 39 | '', 40 | '', 41 | 'Require all denied', 42 | '', 43 | '', 44 | ]; 45 | file_put_contents("$directory/.htaccess", join(PHP_EOL, $lines)); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /migrations/1.0.0-initial-schema.php: -------------------------------------------------------------------------------- 1 | query( 9 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_site_stats ( 10 | date DATE PRIMARY KEY NOT NULL, 11 | visitors MEDIUMINT UNSIGNED NOT NULL, 12 | pageviews MEDIUMINT UNSIGNED NOT NULL 13 | ) ENGINE=INNODB CHARACTER SET=ascii" 14 | ); 15 | 16 | $wpdb->query( 17 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_post_stats ( 18 | date DATE NOT NULL, 19 | id BIGINT(20) UNSIGNED NOT NULL, 20 | visitors MEDIUMINT UNSIGNED NOT NULL, 21 | pageviews MEDIUMINT UNSIGNED NOT NULL, 22 | PRIMARY KEY (date, id) 23 | ) ENGINE=INNODB CHARACTER SET=ascii" 24 | ); 25 | 26 | $wpdb->query( 27 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_referrer_stats ( 28 | date DATE NOT NULL, 29 | id MEDIUMINT UNSIGNED NOT NULL, 30 | visitors MEDIUMINT UNSIGNED NOT NULL, 31 | pageviews MEDIUMINT UNSIGNED NOT NULL, 32 | PRIMARY KEY (date, id) 33 | ) ENGINE=INNODB CHARACTER SET=ascii" 34 | ); 35 | 36 | $wpdb->query( 37 | "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}koko_analytics_referrer_urls ( 38 | id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 39 | url VARCHAR(255) NOT NULL, 40 | UNIQUE INDEX (url) 41 | ) ENGINE=INNODB CHARACTER SET=ascii" 42 | ); 43 | -------------------------------------------------------------------------------- /.github/workflows/build-test-lint.yml: -------------------------------------------------------------------------------- 1 | name: Build, Test and Lint 2 | 3 | on: [ push, pull_request ] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | 11 | - name: Setup PHP 12 | id: setup-php 13 | uses: shivammathur/setup-php@v2 14 | with: 15 | php-version: 'highest' 16 | 17 | # Configure Composer to use GitHub action cache 18 | - name: Get Composer Cache Directory 19 | id: composer-cache 20 | run: | 21 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT 22 | - uses: actions/cache@v4 23 | with: 24 | path: ${{ steps.composer-cache.outputs.dir }} 25 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 26 | restore-keys: | 27 | ${{ runner.os }}-composer- 28 | 29 | - name: Validate composer.json and composer.lock 30 | run: composer validate 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | - name: Run PHPUnit 36 | run: composer run-script test 37 | 38 | - name: Run PHPCS 39 | run: composer run-script lint 40 | 41 | - name: Install NPM dependencies 42 | run: npm ci 43 | 44 | - name: Run lint 45 | run: npm run lint 46 | 47 | - name: Run benchmark 48 | run: php tests/benchmarks/run-benchmark.php 49 | 50 | -------------------------------------------------------------------------------- /tests/benchmarks/read-line.php: -------------------------------------------------------------------------------- 1 | false]); 36 | assert($read == $data); 37 | fseek($fh, 0); 38 | }); 39 | printf("unserialize:\t %6.2f\n", $time); 40 | 41 | // prepare json_encode file 42 | ftruncate($fh, 0); 43 | fputs($fh, json_encode($data) . PHP_EOL); 44 | fseek($fh, 0); 45 | $time = bench(function () use ($fh, $data) { 46 | $read = json_decode(trim(fgets($fh))); 47 | assert($read == $data); 48 | fseek($fh, 0); 49 | }); 50 | printf("json_decode:\t %6.2f\n", $time); 51 | 52 | fclose($fh); 53 | -------------------------------------------------------------------------------- /src/Router.php: -------------------------------------------------------------------------------- 1 | null], home_url()); 22 | break; 23 | 24 | default: 25 | throw new InvalidArgumentException('No such route: ' . $name); 26 | break; 27 | } 28 | } 29 | 30 | public static function is(string $name): bool 31 | { 32 | global $pagenow; 33 | 34 | switch ($name) { 35 | case 'dashboard-embedded': 36 | return $pagenow === 'index.php' && ($_GET['page'] ?? '') === 'koko-analytics' && ! isset($_GET['tab']); 37 | break; 38 | 39 | case 'dashboard-standalone': 40 | return isset($_GET['koko-analytics-dashboard']) || str_contains($_SERVER['REQUEST_URI'] ?? '', '/koko-analytics-dashboard/'); 41 | break; 42 | 43 | default: 44 | break; 45 | } 46 | 47 | return false; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Admin/Data_Reset.php: -------------------------------------------------------------------------------- 1 | query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_site_stats;"); 25 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_post_stats;"); 26 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_paths;"); 27 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_referrer_stats;"); 28 | $wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}koko_analytics_referrer_urls;"); 29 | delete_option('koko_analytics_realtime_pageview_count'); 30 | 31 | // delete version option so that migrations re-create all database tables on next page load 32 | delete_option('koko_analytics_version'); 33 | 34 | // redirect with success message 35 | $settings_page = admin_url('options-general.php?page=koko-analytics-settings&tab=data'); 36 | wp_safe_redirect(add_query_arg(['message' => urlencode(__('Statistics successfully reset', 'koko-analytics')) ], $settings_page)); 37 | exit; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Normalizers/Path.php: -------------------------------------------------------------------------------- 1 | 1, 'p' => 1, 'tag' => 1, 'cat' => 1, 'product' => 1, 'attachment_id' => 1])); 22 | 23 | // trim trailing question mark & replace url with new sanitized url 24 | $value = rtrim($value, '?'); 25 | } 26 | 27 | // in case wordpress is served from a subdirectory, use the path relative to the wordpress root page 28 | $home_path = parse_url(home_url(''), PHP_URL_PATH); 29 | if ($home_path && $home_path !== '/' && str_starts_with($value, $home_path)) { 30 | $value = substr($value, strlen($home_path)); 31 | } 32 | 33 | // if value ends with /amp/, remove suffix (but leave trailing slash) 34 | if (str_ends_with($value, '/amp/')) { 35 | $value = substr($value, 0, strlen($value) - 4); 36 | } 37 | 38 | return $value; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Resources/views/settings/performance.php: -------------------------------------------------------------------------------- 1 | 6 | 7 |

8 | 9 |
10 | 11 |

12 | 13 |

14 |
15 | 16 | 17 | 18 | 19 |
20 |

' . Endpoint_Installer::get_file_name() . ''); ?>

21 | 22 |

23 | 24 |
25 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | $vars['post_type'], 38 | 'number' => 100, // to support blocks with pagination 39 | 'days' => 30, 40 | ]); 41 | 42 | // WP_Query checks for post__in argument using ! empty, so we pass a dummy array here in case we didn't find any posts with stats over last N days 43 | if (count($post_ids) === 0) { 44 | $post_ids = [ 0 ]; 45 | } 46 | 47 | $vars['ignore_sticky_posts'] = true; 48 | $vars['orderby'] = 'post__in'; 49 | $vars['post__in'] = $post_ids; 50 | return $vars; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/benchmarks/run-benchmark.php: -------------------------------------------------------------------------------- 1 | memory; 39 | $times[] = $data->time; 40 | } 41 | 42 | [$time_min, $time_mean, $time_max] = analyze($times); 43 | 44 | echo $opcache ? "with opcache: " : "without opcache: "; 45 | echo "min: $time_min\tmean: $time_mean\tmax: $time_max\n"; 46 | } 47 | 48 | $root = __DIR__; 49 | $ph = popen("php -S localhost:8080 -q -t {$root} &2>/dev/null", "r"); 50 | if (!$ph) { 51 | echo "Error starting HTTP server\n"; 52 | exit(1); 53 | } 54 | sleep(2); 55 | 56 | bench(100, true); 57 | bench(100, false); 58 | 59 | pclose($ph); 60 | -------------------------------------------------------------------------------- /src/Resources/views/standalone.php: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Koko Analytics 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /assets/src/js/dashboard.js: -------------------------------------------------------------------------------- 1 | // update date_start and date_end 's whenever a preset is selected 2 | var datePresetSelect = document.querySelector('#ka-date-presets'); 3 | var dateStartInput = document.querySelector('#ka-date-start'); 4 | var dateEndInput = document.querySelector('#ka-date-end'); 5 | datePresetSelect && datePresetSelect.addEventListener('change', function() { 6 | dateStartInput.disabled = true; 7 | dateEndInput.disabled = true; 8 | this.form.submit(); 9 | }); 10 | 11 | // set 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 |
14 |

15 | find your public dashboard here.', 'koko-analytics'), esc_attr($public_dashboard_url)), [ 'a' => [ 'href' => [] ] ]); ?> 16 |

17 |
18 | 19 |
20 | 21 | 29 |

30 |
31 | 32 | 33 | 34 |
35 | 36 |
37 | 38 | -------------------------------------------------------------------------------- /src/Shortcodes/Shortcode_Site_Counter.php: -------------------------------------------------------------------------------- 1 | 365 * 10, 31 | 'metric' => 'pageviews', 32 | 'global' => false, 33 | ]; 34 | $args = shortcode_atts($default_args, $args, self::SHORTCODE); 35 | $args['days'] = abs((int) $args['days']); 36 | $path = $args['global'] && $args['global'] !== 'false' && $args['global'] !== '0' && $args['global'] !== 'no' ? '' : self::get_post_path(); 37 | 38 | 39 | 40 | $start_date_str = $args['days'] === 0 ? 'today midnight' : "-{$args['days']} days"; 41 | $timezone = wp_timezone(); 42 | $start_date = (new DateTime($start_date_str, $timezone))->format('Y-m-d'); 43 | $end_date = (new DateTime('tomorrow, midnight', $timezone))->format('Y-m-d'); 44 | 45 | $cache_key = "ka_counter_" . md5("{$path}-{$args['metric']}-{$args['days']}"); 46 | $count = get_transient($cache_key); 47 | if (false === $count) { 48 | $stats = new Stats(); 49 | $totals = $stats->get_totals($start_date, $end_date, $path); 50 | $count = $args['metric'] === 'visitors' ? $totals->visitors : $totals->pageviews; 51 | set_transient($cache_key, $count, 5 * 60); 52 | } 53 | 54 | return '' . $count . ''; 55 | } 56 | 57 | // Gets the path to whatever post is currently in "the loop" 58 | public static function get_post_path() 59 | { 60 | $permalink = get_the_permalink(); 61 | $url_parts = parse_url($permalink); 62 | $path = $url_parts['path']; 63 | if (!empty($url_parts['query'])) { 64 | $path .= '?' . $url_parts['query']; 65 | } 66 | return Normalizer::path($path); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/benchmarks/parse-url.php: -------------------------------------------------------------------------------- 1 | 1, 'p' => 1, 'tag' => 1, 'cat' => 1, 'product' => 1, 'attachment_id' => 1])); 20 | 21 | // trim trailing question mark & replace url with new sanitized url 22 | $url = rtrim($url, '?'); 23 | } 24 | 25 | return $url; 26 | } 27 | 28 | function normalize_explode(string $url): string 29 | { 30 | // remove # from URL 31 | if (($pos = strpos($url, '#')) !== false) { 32 | $url = substr($url, 0, $pos); 33 | } 34 | 35 | // if URL contains query string, parse it and only keep certain parameters 36 | if (($pos = strpos($url, '?')) !== false) { 37 | $query_string = substr($url, $pos + 1); 38 | $url = substr($url, 0, $pos + 1); 39 | $allowed_params = [ 'page_id', 'p', 'tag', 'cat', 'product', 'attachment_id']; 40 | 41 | foreach (explode('&', $query_string) as $a) { 42 | $parts = explode('=', $a); 43 | $left = $parts[0]; 44 | $right = $parts[1] ?? ''; 45 | 46 | if (in_array($left, $allowed_params)) { 47 | $url .= $left; 48 | 49 | if ($right) { 50 | $url .= '='; 51 | $url .= $right; 52 | $url .= '&'; 53 | } 54 | } 55 | } 56 | 57 | $url = rtrim($url, '?&'); 58 | } 59 | 60 | return $url; 61 | } 62 | 63 | 64 | $n = 10000; 65 | 66 | $time = bench(function () { 67 | $normalized = normalize_with_parse_str("/about/?utm_source=source&utm_medium=medium&p=100&utm_campaign=campaign&"); 68 | bench_assert("/about/?p=100", $normalized); 69 | }, $n); 70 | 71 | printf("normalize_with_parse_str:\t %.2f ns (%.2f per it)\n", $time, $time / $n); 72 | 73 | $time = bench(function () { 74 | $normalized = normalize_explode("/about/?utm_source=source&utm_medium=medium&p=100&utm_campaign=campaign&"); 75 | bench_assert("/about/?p=100", $normalized); 76 | }, $n); 77 | printf("normalize_explode:\t %.2f ns (%.2f per it)\n", $time, $time / $n); 78 | -------------------------------------------------------------------------------- /src/Fingerprinter.php: -------------------------------------------------------------------------------- 1 | ', 31 | 'Order deny,allow', 32 | 'Deny from all', 33 | '', 34 | '', 35 | 'Require all denied', 36 | '', 37 | '', 38 | ]; 39 | file_put_contents("$sessions_dir/.htaccess", join(PHP_EOL, $lines)); 40 | } 41 | 42 | public static function run_daily_maintenance(): void 43 | { 44 | $settings = get_settings(); 45 | if ($settings['tracking_method'] !== 'fingerprint') { 46 | return; 47 | } 48 | 49 | $upload_dir = get_upload_dir(); 50 | $sessions_dir = "{$upload_dir}/sessions"; 51 | $seed_file = "{$sessions_dir}/.daily_seed"; 52 | 53 | // ensure directory exists 54 | self::create_storage_dir(); 55 | 56 | // remove every file in directory 57 | foreach (new \DirectoryIterator($sessions_dir) as $f) { 58 | if ($f->isDot()) { 59 | continue; 60 | } 61 | 62 | unlink($f->getPathname()); 63 | } 64 | 65 | // create new seed file 66 | file_put_contents($seed_file, bin2hex(random_bytes(16))); 67 | } 68 | 69 | public static function setup_scheduled_event(): void 70 | { 71 | if (! wp_next_scheduled('koko_analytics_rotate_fingerprint_seed')) { 72 | $time_next_midnight = (new \DateTimeImmutable('tomorrow, midnight', wp_timezone()))->getTimestamp(); 73 | wp_schedule_event($time_next_midnight, 'daily', 'koko_analytics_rotate_fingerprint_seed'); 74 | } 75 | } 76 | 77 | public static function clear_scheduled_event(): void 78 | { 79 | wp_clear_scheduled_hook('koko_analytics_rotate_fingerprint_seed'); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Resources/functions/global.php: -------------------------------------------------------------------------------- 1 | The number of results to return 26 | * `post_type` => A single post type or an array of post types to return 27 | * `days` => Specified the last X number of days for which the most viewed posts should be returned 28 | * 29 | * @param array $args 30 | * @return array 31 | * @since 1.1 32 | */ 33 | function koko_analytics_get_most_viewed_posts(array $args = []): array 34 | { 35 | return KokoAnalytics\get_most_viewed_posts($args); 36 | } 37 | 38 | 39 | /** 40 | * Returns the number of realtime pageviews, for example in the last hour or in the last 5 minutes. 41 | * Does not work with timestamps over 1 hour ago. 42 | * 43 | * Examples: 44 | * koko_analytics_get_realtime_pageview_count('-5 minutes'); 45 | * koko_analytics_get_realtime_pageview_count('-1 hour'); 46 | * 47 | * @since 1.1 48 | * @param null|string|int $since An integer timestamp (seconds since Unix epoch) or a relative time string in the format that strtotime() understands. Defaults to "-5 minutes" 49 | * @return int 50 | * @see strtotime 51 | */ 52 | function koko_analytics_get_realtime_pageview_count($since = '-5 minutes'): int 53 | { 54 | return KokoAnalytics\get_realtime_pageview_count($since); 55 | } 56 | 57 | /** 58 | * Writes a new pageview to the buffer file, to be aggregated during the next time `koko_analytics_aggregate_stats` runs. 59 | * 60 | * @param string $path The (normalized) path which was viewed 61 | * @param int $post_id The post ID to increment the pageviews count for. 0 if not a singular post type. 62 | * @param bool $new_visitor Whether this is a new site visitor. 63 | * @param bool $unique_pageview Whether this was an unique pageview. (Ie the first time this visitor views this page today). 64 | * @param string $referrer_url External URL that this visitor came from, or empty string if direct traffic or coming from internal link. 65 | * @return bool 66 | * @since 1.1 67 | */ 68 | function koko_analytics_track_pageview(string $path, int $post_id = 0, bool $new_visitor = false, bool $unique_pageview = false, string $referrer_url = ''): bool 69 | { 70 | $data = [ 71 | 'p', 72 | \time(), 73 | $path, 74 | $post_id, 75 | (int) $new_visitor, 76 | (int) $unique_pageview, 77 | $referrer_url, 78 | ]; 79 | return KokoAnalytics\collect_in_file($data); 80 | } 81 | -------------------------------------------------------------------------------- /src/Resources/views/settings/help.php: -------------------------------------------------------------------------------- 1 | 2 |

3 |
4 |
    5 |
  • ', ''); ?>
  • 6 |
  • ', ''); ?>
  • 7 |
  • ', ''); ?>
  • 8 |
9 |
10 | 11 | 0) { ?> 30 |
31 |

32 | 37 |
38 | 39 | 40 | 41 |
42 |

43 | 59 |
60 | -------------------------------------------------------------------------------- /src/Admin/Data_Import.php: -------------------------------------------------------------------------------- 1 | urlencode(__('Something went wrong trying to process your import file.', 'koko-analytics'))], $settings_page)); 25 | exit; 26 | } 27 | 28 | // don't accept MySQL blobs over 16 MB 29 | if ($_FILES['import-file']['size'] > 16000000) { 30 | wp_safe_redirect(add_query_arg(['error' => urlencode(__('Sorry, your import file is too large. Please import it into your database in some other way.', 'koko-analytics'))], $settings_page)); 31 | exit; 32 | } 33 | 34 | // try to increase time limit 35 | @set_time_limit(300); 36 | 37 | // read SQL from upload file 38 | $sql = file_get_contents($_FILES['import-file']['tmp_name']); 39 | 40 | // verify file looks like a Koko Analytics export file 41 | if (!preg_match('/^(--|DELETE|SELECT|INSERT|TRUNCATE|CREATE|DROP)/', $sql)) { 42 | wp_safe_redirect(add_query_arg(['error' => urlencode(__('Sorry, the uploaded import file does not look like a Koko Analytics export file', 'koko-analytics')) ], $settings_page)); 43 | exit; 44 | } 45 | 46 | // good to go, let's run the SQL 47 | try { 48 | self::run($sql); 49 | } catch (\Exception $e) { 50 | wp_safe_redirect(add_query_arg([ 'error' => urlencode(__('Something went wrong trying to process your import file.', 'koko-analytics') . "\n" . $e->getMessage()) ], $settings_page)); 51 | exit; 52 | } 53 | 54 | // unlink tmp file 55 | unlink($_FILES['import-file']['tmp_name']); 56 | 57 | // redirect with success message 58 | wp_safe_redirect(add_query_arg([ 'message' => urlencode(__('Database was successfully imported from the given file', 'koko-analytics')) ], $settings_page)); 59 | exit; 60 | } 61 | 62 | protected static function run(string $sql): void 63 | { 64 | if ($sql === '') { 65 | return; 66 | } 67 | 68 | /** @var \wpdb $wpdb */ 69 | global $wpdb; 70 | $statements = explode(';', $sql); 71 | foreach ($statements as $statement) { 72 | // skip over empty statements 73 | $statement = trim($statement); 74 | if (!$statement) { 75 | continue; 76 | } 77 | 78 | $result = $wpdb->query($statement); 79 | 80 | if ($result === false) { 81 | throw new Exception($wpdb->last_error); 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Migrations.php: -------------------------------------------------------------------------------- 1 | prefix = rtrim($prefix, '_'); 24 | $this->option_name = str_ends_with($this->prefix, '_version') ? $this->prefix : "{$this->prefix}_version"; 25 | $this->version_from = isset($_GET["{$this->prefix}_migrate_from_version"]) && current_user_can('manage_options') ? $_GET["{$this->prefix}_migrate_from_version"] : get_option($this->option_name, '0.0.0'); 26 | $this->version_to = $version_to; 27 | $this->migrations_dir = $migrations_dir; 28 | } 29 | 30 | public function maybe_run(): void 31 | { 32 | if (\version_compare($this->version_from, $this->version_to, '>=')) { 33 | return; 34 | } 35 | 36 | // check if migrations not already running 37 | $transient_key = "{$this->prefix}_migrations_running"; 38 | $transient_timeout = 10; 39 | $previous_run_start = get_transient($transient_key); 40 | if ($previous_run_start && $previous_run_start > time() - $transient_timeout) { 41 | return; 42 | } 43 | 44 | set_transient($transient_key, time(), $transient_timeout); 45 | $this->run(); 46 | delete_transient($transient_key); 47 | } 48 | 49 | /** 50 | * Run the various migration files, all the way up to the latest version 51 | */ 52 | protected function run(): void 53 | { 54 | $files = glob(rtrim($this->migrations_dir, '/') . '/*.php'); 55 | if (! is_array($files)) { 56 | return; 57 | } 58 | 59 | // run each migration file 60 | foreach ($files as $file) { 61 | $this->handle_file($file); 62 | } 63 | 64 | // update database version to current code version 65 | update_option($this->option_name, $this->version_to, true); 66 | } 67 | 68 | /** 69 | * @param string Absolute path to migration file 70 | */ 71 | protected function handle_file(string $file): void 72 | { 73 | $migration = basename($file); 74 | $parts = explode('-', $migration); 75 | $migration_version = $parts[0]; 76 | 77 | // check if migration file is not for an even higher version 78 | if (version_compare($migration_version, $this->version_to, '>')) { 79 | return; 80 | } 81 | 82 | // check if we ran migration file before. 83 | if (version_compare($this->version_from, $migration_version, '>=')) { 84 | return; 85 | } 86 | 87 | // run migration file 88 | include $file; 89 | 90 | // update option so later runs start after this migration 91 | $this->version_from = $migration_version; 92 | update_option($this->option_name, $migration_version, true); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /bin/create-package: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | # Check if VERSION argument was supplied 6 | if [ "$#" -lt 1 ]; then 7 | echo "1 parameters expected, $# found" 8 | echo "Usage: package.sh " 9 | exit 1 10 | fi 11 | 12 | PLUGIN_SLUG=$(basename "$PWD") 13 | PLUGIN_FILE="$PLUGIN_SLUG.php" 14 | VERSION=$1 15 | PACKAGE_FILE="$PWD/../$PLUGIN_SLUG-$VERSION.zip" 16 | 17 | # Check if we're inside plugin directory 18 | if [ ! -e "$PLUGIN_FILE" ]; then 19 | echo "Plugin entry file not found. Please run this command from inside the $PLUGIN_SLUG directory." 20 | exit 1 21 | fi 22 | 23 | # Check if there are uncommitted changes 24 | if [ -n "$(git status --porcelain)" ]; then 25 | echo "There are uncommitted changes. Please commit those changes before initiating a release." 26 | exit 1 27 | fi 28 | 29 | # Update referrer blocklist 30 | curl https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt -k -o data/referrer-blocklist 31 | git add data/referrer-blocklist || true 32 | git commit -m "update referrer blocklist from https://raw.githubusercontent.com/matomo-org/referrer-spam-blacklist/master/spammers.txt" || true 33 | 34 | # Update external 35 | bin/update-external-strings || true 36 | 37 | # Build (optimized) client-side assets 38 | npm run build 39 | 40 | # Update version numbers in code 41 | sed -i "s/^Version:.*$/Version: $VERSION/g" "$PLUGIN_FILE" 42 | sed -i "s/define(\s*'\(.*_VERSION\)'\s*,\s*'.*'\s*);/define('\1', '$VERSION');/g" "$PLUGIN_FILE" 43 | sed -i "s/^Stable tag:.*$/Stable tag: $VERSION/g" "readme.txt" 44 | 45 | # Copy over changelog from CHANGELOG.md to readme.txt 46 | # Ref: https://git.sr.ht/~dvko/dotfiles/tree/master/item/bin/wp-update-changelog 47 | wp-update-changelog 48 | 49 | # Update git 50 | git add . -A || true 51 | git commit -m "v$VERSION" || true 52 | 53 | # Move up one directory level because we need plugin directory in ZIP file 54 | cd .. 55 | 56 | # Check if there is an existing file for this release already 57 | rm -f "$PACKAGE_FILE" 58 | 59 | # Create archive (excl. development files) 60 | zip -r "$PACKAGE_FILE" "$PLUGIN_SLUG" \ 61 | -x "$PLUGIN_SLUG/.*" \ 62 | -x "$PLUGIN_SLUG/bin/*" \ 63 | -x "$PLUGIN_SLUG/vendor/*" \ 64 | -x "$PLUGIN_SLUG/node_modules/*" \ 65 | -x "$PLUGIN_SLUG/tests/*" \ 66 | -x "$PLUGIN_SLUG/webpack.config.js" \ 67 | -x "$PLUGIN_SLUG/eslint.config.mjs" \ 68 | -x "$PLUGIN_SLUG/package.json" \ 69 | -x "$PLUGIN_SLUG/package-lock.json" \ 70 | -x "$PLUGIN_SLUG/composer.json" \ 71 | -x "$PLUGIN_SLUG/*.lock" \ 72 | -x "$PLUGIN_SLUG/phpcs.xml" \ 73 | -x "$PLUGIN_SLUG/phpunit.xml.dist" \ 74 | -x "$PLUGIN_SLUG/*.sh" \ 75 | -x "$PLUGIN_SLUG/assets/src/*" \ 76 | -x "$PLUGIN_SLUG/assets/dist/img/screenshot-*" \ 77 | -x "$PLUGIN_SLUG/assets/dist/img/banner-*" \ 78 | -x "$PLUGIN_SLUG/code-snippets/*" 79 | 80 | # Move back into plugin directory 81 | cd "$PLUGIN_SLUG" 82 | 83 | SIZE=$(ls -lh "$PACKAGE_FILE" | cut -d' ' -f5) 84 | echo "$(basename "$PACKAGE_FILE") created ($SIZE)" 85 | 86 | # Create tag in Git and push to remote 87 | printf "\nPush v$VERSION to GitHub (y)" 88 | read CONFIRM 89 | if [[ "$CONFIRM" != "n" ]]; then 90 | git tag "$VERSION" 91 | git push origin main 92 | git push origin "tags/$VERSION" 93 | fi; 94 | -------------------------------------------------------------------------------- /src/Import/Importer.php: -------------------------------------------------------------------------------- 1 | urlencode($error)]); 23 | } 24 | 25 | /** 26 | * @param array $rows An array of arrays with the following elements: date, path, post_id, visitors, pageviews 27 | */ 28 | protected static function bulk_insert_page_stats(array $rows): void 29 | { 30 | /** @var wpdb $wpdb */ 31 | global $wpdb; 32 | 33 | // return early if nothing to do 34 | if (count($rows) == 0) { 35 | return; 36 | } 37 | 38 | $path_ids = Path_Repository::upsert(array_map(function ($r) { 39 | return $r[1]; 40 | }, $rows)); 41 | 42 | $values = []; 43 | foreach ($rows as $r) { 44 | array_push($values, $r[0], $path_ids[$r[1]], $r[2], $r[3], $r[4]); 45 | } 46 | $placeholders = rtrim(str_repeat('(%s,%d,%d,%d,%d),', count($rows)), ','); 47 | 48 | $query = $wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_post_stats(date, path_id, post_id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values); 49 | $wpdb->query($query); 50 | 51 | if ($wpdb->last_error !== '') { 52 | throw new Exception(__("A database error occurred: ", 'koko-analytics') . " {$wpdb->last_error}"); 53 | } 54 | } 55 | 56 | /** 57 | * @param array $rows An array of arrays with the following elements: date, referrer, visitors, pageviews 58 | */ 59 | protected static function bulk_insert_referrer_stats(array $rows): void 60 | { 61 | /** @var wpdb $wpdb */ 62 | global $wpdb; 63 | 64 | // return early if nothing to do 65 | if (count($rows) == 0) { 66 | return; 67 | } 68 | 69 | $ids = Referrer_Repository::upsert(array_map(function ($r) { 70 | return $r[1]; 71 | }, $rows)); 72 | 73 | $values = []; 74 | foreach ($rows as $r) { 75 | array_push($values, $r[0], $ids[$r[1]], $r[2], $r[3]); 76 | } 77 | $placeholders = rtrim(str_repeat('(%s,%d,%d,%d),', count($rows)), ','); 78 | 79 | $query = $wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_referrer_stats(date, id, visitors, pageviews) VALUES {$placeholders} ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", $values); 80 | $wpdb->query($query); 81 | 82 | if ($wpdb->last_error !== '') { 83 | throw new Exception(__("A database error occurred: ", 'koko-analytics') . " {$wpdb->last_error}"); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Aggregator.php: -------------------------------------------------------------------------------- 1 | ') { 60 | continue; 61 | } 62 | 63 | $params = \unserialize($line, ['allowed_classes' => false]); 64 | if (! \is_array($params)) { 65 | error_log('Koko Analytics: unserialize error encountered while processing line in buffer file'); 66 | continue; 67 | } 68 | $type = \array_shift($params); 69 | 70 | // core aggregator 71 | $pageview_aggregator->line($type, $params); 72 | 73 | // add-on aggregators 74 | do_action('koko_analytics_aggregate_line', $type, $params); 75 | } 76 | 77 | // close file & remove it from filesystem 78 | \fclose($file_handle); 79 | \unlink($tmp_filename); 80 | 81 | // tell aggregators to write their results to the database 82 | $pageview_aggregator->finish(); 83 | do_action('koko_analytics_aggregate_finish'); 84 | } 85 | 86 | public static function setup_scheduled_event(): void 87 | { 88 | if (! wp_next_scheduled('koko_analytics_aggregate_stats')) { 89 | wp_schedule_event(time() + 60, 'koko_analytics_stats_aggregate_interval', 'koko_analytics_aggregate_stats'); 90 | } 91 | } 92 | 93 | public static function clear_scheduled_event(): void 94 | { 95 | wp_clear_scheduled_hook('koko_analytics_aggregate_stats'); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/mocks.php: -------------------------------------------------------------------------------- 1 | '/tmp', 132 | ]; 133 | } 134 | 135 | function wp_remote_get($url) 136 | { 137 | return null; 138 | } 139 | 140 | function wp_remote_retrieve_response_code($response) 141 | { 142 | return ''; 143 | } 144 | 145 | function wp_remote_retrieve_headers($response) 146 | { 147 | return []; 148 | } 149 | 150 | function is_wp_error($thing) 151 | { 152 | return false; 153 | } 154 | 155 | function _deprecated_function($function, $version, $replacement) 156 | { 157 | } 158 | 159 | function wp_timezone(): DateTimeZone 160 | { 161 | return new DateTimeZone('UTC'); 162 | } 163 | 164 | class wpdb_mock 165 | { 166 | public $prefix = ''; 167 | public function query($sql) 168 | { 169 | return null; 170 | } 171 | 172 | public function prepare($query, ...$params) 173 | { 174 | return $query; 175 | } 176 | } 177 | 178 | global $wpdb; 179 | $wpdb = new wpdb_mock(); 180 | -------------------------------------------------------------------------------- /src/Normalizers/Referrer.php: -------------------------------------------------------------------------------- 1 | 'https://$3.$1', 18 | '/^android-app:\/\/m\.facebook\.com/' => 'https://facebook.com', 19 | 20 | // popular iOS apps 21 | '/^ios-app:\/\/429047995.*/' => 'https://pinterest.com', 22 | '/^ios-app:\/\/1064216828.*/' => 'https://reddit.com', 23 | '/^ios-app:\/\/284882215.*/' => 'https://facebook.com', 24 | '/^ios-app:\/\/389801252.*/' => 'https://instagram.com', 25 | 26 | // popular websites 27 | '/^https?:\/\/(?:www\.)?(google|bing|ecosia)\.([a-z]{2,4}(?:\.[a-z]{2,4})?)(?:\/search|\/url)?/' => 'https://$1.$2', 28 | '/^https?:\/\/(?:[a-z-]+\.)?l?facebook\.com(?:\/l\.php)?/' => 'https://facebook.com', 29 | '/^https?:\/\/(?:[a-z-]+\.)?l?instagram\.com(?:\/l\.php)?/' => 'https://instagram.com', 30 | '/^https?:\/\/(?:[a-z-]+\.)?linkedin\.com\/feed.*/' => 'https://linkedin.com', 31 | '/^https?:\/\/(?:[a-z-]+\.)?pinterest\.com/' => 'https://pinterest.com', 32 | '/^https?:\/\/(?:[a-z-]+\.)?baidu\.com.*/' => 'https://baidu.com', 33 | '/^https?:\/\/(?:[a-z-]+\.)?yandex\.ru\/.*/' => 'https://yandex.ru', 34 | '/^https?:\/\/(?:[a-z-]+\.)?search\.yahoo\.com\/.*/' => 'https://search.yahoo.com', 35 | '/^https?:\/\/(?:[a-z-]+\.)?reddit\.com.*/' => 'https://reddit.com', 36 | '/^https?:\/\/(?:[a-z0-9]{1,8}\.)+sendib(?:m|t)[0-9]\.com.*/' => 'https://brevo.com', 37 | 38 | ]; 39 | 40 | $aggregations = apply_filters('koko_analytics_url_aggregations', $aggregations); 41 | $normalized_value = (string) preg_replace(array_keys($aggregations), array_values($aggregations), $value, 1); 42 | if (preg_last_error() !== PREG_NO_ERROR) { 43 | error_log("Koko Analytics: preg_replace error in Referrer::normalize('$value'): " . preg_last_error_msg()); 44 | return $value; 45 | } 46 | $value = $normalized_value; 47 | 48 | // limit resulting value to just host 49 | $url_parts = parse_url($value); 50 | 51 | // check for seriously malformed url's 52 | if ($url_parts === false || empty($url_parts['host'])) { 53 | return ''; 54 | } 55 | $result = $url_parts['host']; 56 | 57 | // strip www. prefix 58 | if (str_starts_with($result, 'www.')) { 59 | $result = substr($result, 4); 60 | } 61 | 62 | // add path if domain is whitelisted 63 | $whitelisted_domains = ['wordpress.org', 'kokoanalytics.com', 'github.com', 'reddit.com', 'indiehackers.com']; 64 | $whitelisted_domains = apply_filters('koko_analytics_whitelisted_referrer_domains', $whitelisted_domains); 65 | if (in_array($result, $whitelisted_domains) && !empty($url_parts['path']) && $url_parts['path'] !== '/') { 66 | $result .= $url_parts['path']; 67 | } 68 | 69 | return $result; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/FunctionsTest.php: -------------------------------------------------------------------------------- 1 | assertEquals(extract_pageview_data([]), []); 19 | $this->assertEquals(extract_pageview_data(['r' => 'http://www.kokoanalytics.com']), []); 20 | 21 | // complete but invalid 22 | $this->assertEquals(extract_pageview_data(['po' => '']), []); 23 | $this->assertEquals(extract_pageview_data(['po' => '1', 'r' => 'not an url']), []); 24 | 25 | // complete and valid 26 | foreach ( 27 | [ 28 | [['pa' => '/', 'po' => '1'], ['p', null, '/', 1, 1, 1, '']], 29 | 30 | ] as [$input, $expected] 31 | ) { 32 | $actual = extract_pageview_data($input); 33 | $this->assertGreaterThan(0, count($actual)); 34 | $this->assertEquals($expected[0], $actual[0]); // type indicator 35 | $this->assertIsInt($actual[1]); // timestamp 36 | $this->assertEquals($expected[2], $actual[2]); // path 37 | $this->assertEquals($expected[3], $actual[3]); // post id 38 | $this->assertEquals($expected[4], $actual[4]); 39 | $this->assertEquals($expected[5], $actual[5]); 40 | $this->assertEquals($expected[6], $actual[6]); 41 | } 42 | } 43 | 44 | public function testExtractEventData(): void 45 | { 46 | // incomplete 47 | $this->assertEquals(extract_event_data([]), []); 48 | $this->assertEquals(extract_event_data(['e' => 'Event']), []); 49 | $this->assertEquals(extract_event_data(['p' => 'Param']), []); 50 | $this->assertEquals(extract_event_data(['v' => '1']), []); 51 | $this->assertEquals(extract_event_data(['e' => 'Event', 'v' => '1']), []); 52 | $this->assertEquals(extract_event_data(['p' => 'Param', 'v' => '1']), []); 53 | 54 | // complete but invalid 55 | $this->assertEquals(extract_event_data(['e' => 'Event', 'p' => 'Param', 'v' => 'nan']), []); 56 | $this->assertEquals(extract_event_data(['e' => '', 'p' => 'Param', 'v' => '100']), []); 57 | 58 | // complete and valid 59 | $actual = extract_event_data(['e' => 'Event', 'p' => 'Param', 'v' => '100']); 60 | $expected = ['e', 'Event', 'Param', 1, 100]; 61 | $this->assertEquals($expected[0], $actual[0]); 62 | $this->assertEquals($expected[1], $actual[1]); 63 | $this->assertEquals($expected[2], $actual[2]); 64 | $this->assertEquals($expected[3], $actual[3]); 65 | $this->assertEquals($expected[4], $actual[4]); 66 | } 67 | 68 | public function testGetClientIp(): void 69 | { 70 | $this->assertEquals(get_client_ip(), ''); 71 | 72 | $_SERVER['REMOTE_ADDR'] = '1.1.1.1'; 73 | $this->assertEquals(get_client_ip(), '1.1.1.1'); 74 | 75 | $_SERVER['HTTP_X_FORWARDED_FOR'] = '2.2.2.2'; 76 | $this->assertEquals(get_client_ip(), '2.2.2.2'); 77 | 78 | $_SERVER['HTTP_X_FORWARDED_FOR'] = '3.3.3.3, 2.2.2.2'; 79 | $this->assertEquals(get_client_ip(), '3.3.3.3'); 80 | 81 | $_SERVER['HTTP_X_FORWARDED_FOR'] = 'not-an-ip'; 82 | $this->assertEquals(get_client_ip(), '1.1.1.1'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Notice_Pro.php: -------------------------------------------------------------------------------- 1 | maybe_show(); 16 | } 17 | 18 | public function get_settings(): array 19 | { 20 | $settings = get_settings(); 21 | $defaults = [ 22 | 'timestamp_installed' => null, 23 | 'dismissed' => false, 24 | ]; 25 | $settings['notice_pro'] = array_merge($defaults, $settings['notice_pro'] ?? []); 26 | return $settings; 27 | } 28 | 29 | private function get_setting(string $key) 30 | { 31 | $settings = $this->get_settings(); 32 | return $settings['notice_pro'][$key]; 33 | } 34 | 35 | private function update_setting(string $key, $value): void 36 | { 37 | $settings = $this->get_settings(); 38 | $settings['notice_pro'][$key] = $value; 39 | update_option('koko_analytics_settings', $settings, true); 40 | } 41 | 42 | public function maybe_show(): void 43 | { 44 | // don't show if user doesn't have capability for managing koko analytics 45 | // don't show if Koko Analytics Pro is installed 46 | if (!current_user_can('manage_koko_analytics') || defined('KOKO_ANALYTICS_PRO_VERSION')) { 47 | return; 48 | } 49 | 50 | if (isset($_GET['ka-notice-pro-dismiss'])) { 51 | $this->update_setting('dismissed', true); 52 | return; 53 | } 54 | 55 | $date_installed = $this->get_setting('timestamp_installed'); 56 | 57 | // if first time loading dashboard, don't show 58 | if ($date_installed === null) { 59 | $this->update_setting('timestamp_installed', time()); 60 | return; 61 | } 62 | 63 | // if installed less than 30 days ago, don't show 64 | if ($date_installed > time() - (86400 * 30)) { 65 | return; 66 | } 67 | 68 | // if previously dismissed, don't show 69 | if ($this->get_setting('dismissed')) { 70 | return; 71 | } 72 | 73 | ?> 74 |
75 |
76 | 77 | 78 | 79 | 80 |

81 |
    82 |
  • 83 |
  • 84 |
  • 85 |
86 | 87 |

88 |
89 |
90 | 19 | 20 | 21 | 22 |
23 |
24 |

25 |

26 | ' . number_format_i18n($totals->pageviews) . '', '' . number_format_i18n($realtime) . ''); ?> 27 |

28 |
29 | 30 |
31 |

32 | 33 |

34 |
35 | 36 |
37 |
38 | 39 | 0 && (count($posts) > 0 || count($referrers) > 0)) { ?> 40 |
41 |
42 | 0) { ?> 43 |
44 |

45 | 46 |

47 |
    48 | 49 |
  • 50 | pageviews); ?> 51 | label); ?> 52 |
  • 53 | 54 |
55 |
56 | 57 | 0) { ?> 58 |
59 |

60 | 61 |

62 |
    63 | 64 |
  • 65 | pageviews); ?> 66 | url)); ?> 67 |
  • 68 | 69 |
70 |
71 | 72 |
73 |
74 | 75 | 76 |

77 | 78 | 79 | 80 |

81 |
82 | -------------------------------------------------------------------------------- /assets/src/js/imports/chart.js: -------------------------------------------------------------------------------- 1 | var chart = document.querySelector('#ka-chart'); 2 | if (chart) { 3 | var tooltip = document.querySelector('.ka-chart--tooltip'); 4 | var arrow = document.querySelector('.ka-chart--tooltip-arrow'); 5 | var bars = chart.querySelectorAll('.bars g'); 6 | var barWidth; 7 | 8 | tooltip.remove(); 9 | document.body.appendChild(tooltip); 10 | 11 | // event listener for removing tooltip 12 | document.body.addEventListener('mouseover', function(e) { 13 | if (tooltip.style.display !== 'none' && !chart.contains(e.target) && !tooltip.contains(e.target)) { 14 | tooltip.style.display = 'none'; 15 | } 16 | }); 17 | 18 | // event listener for showing tooltip 19 | chart.addEventListener('mouseover', function(e) { 20 | if (e.target.tagName !== 'rect') { 21 | return; 22 | } 23 | // update tooltip content 24 | var data = e.target.parentElement.dataset; 25 | tooltip.querySelector('.ka-chart--tooltip-heading').textContent = data.date; 26 | tooltip.querySelector('.ka--visitors').children[0].textContent = data.visitors; 27 | tooltip.querySelector('.ka--pageviews').children[0].textContent = data.pageviews; 28 | 29 | // set tooltip position relative to top-left of document 30 | var availWidth = document.body.clientWidth; 31 | tooltip.style.display = 'block'; 32 | var scrollY = window.pageYOffset !== undefined ? window.pageYOffset : window.scrollTop 33 | var scrollX = 0; //window.pageXOffset !== undefined ? window.pageXOffset : window.scrollLeft 34 | var styles = e.target.parentElement.getBoundingClientRect() // element 35 | var left = Math.round(styles.left + scrollX - 0.5 * tooltip.clientWidth + 0.5 * barWidth); 36 | var top = Math.round(styles.top + scrollY - tooltip.clientHeight); 37 | var offCenter = 0; 38 | 39 | // if tooltip goes off the screen, position it a bit off center 40 | if (left < 12) { 41 | offCenter = -left + 12; 42 | } else if (left + tooltip.clientWidth > availWidth - 12) { 43 | offCenter = availWidth - (left + tooltip.clientWidth) - 12; 44 | } 45 | 46 | // shift tooltip to the right (or left) 47 | left += offCenter; 48 | 49 | // shift arrow to the left (or right) 50 | arrow.style.marginLeft = offCenter === 0 ? 'auto' : ((0.5 * tooltip.clientWidth) - 6 - offCenter) + 'px'; 51 | tooltip.style.left = left + 'px'; 52 | tooltip.style.top = top + 'px'; 53 | }) 54 | } 55 | 56 | export function Chart() { 57 | if (!chart) return; 58 | 59 | var yTicks = chart.querySelectorAll('.axes-y text'); 60 | var i; 61 | var leftOffset = 0; 62 | for (i = 0; i < yTicks.length; i++) { 63 | leftOffset = Math.max(leftOffset, 8 + Math.max(5, yTicks[i].textContent.length * 8)); 64 | } 65 | var tickWidth = Math.max(1, (chart.clientWidth - leftOffset) / bars.length); 66 | barWidth = Math.max(1, tickWidth - 2); 67 | 68 | // update width of each bar now that we know the client width 69 | bars[0].parentElement.style.display = 'none'; 70 | for (i = 0; i < bars.length; i++) { 71 | var x = i * tickWidth + leftOffset + 1; 72 | 73 | // pageviews 74 | bars[i].children[0].setAttribute('x', x); 75 | bars[i].children[0].setAttribute('width', barWidth); 76 | 77 | // visitors 78 | bars[i].children[1].setAttribute('x', x); 79 | bars[i].children[1].setAttribute('width', barWidth); 80 | 81 | // tick 82 | bars[i].children[2].style.display = barWidth === 1 ? 'none' : ''; 83 | x = i * tickWidth + leftOffset + 0.5 * tickWidth; 84 | bars[i].children[2].setAttribute('x1', x); 85 | bars[i].children[2].setAttribute('x2', x); 86 | } 87 | 88 | bars[0].parentElement.style.display = ''; 89 | } 90 | 91 | 92 | -------------------------------------------------------------------------------- /src/Resources/views/settings/plausible_importer.php: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 14 | 15 | 16 |

17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 30 | 31 | 32 | 33 | 34 | 39 | 40 | 41 | 42 | 43 | 48 | 49 |
27 | 28 |

29 |
35 | 36 |

37 | 38 |
44 | 45 |

46 | 47 |
50 | 51 |

52 | 53 | 54 |

55 | 56 |

57 | 58 |

59 |
60 | -------------------------------------------------------------------------------- /src/Pruner.php: -------------------------------------------------------------------------------- 1 | format('Y-m-d'); 36 | 37 | // delete stats older than date above 38 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_site_stats WHERE date < %s", $date)); 39 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_post_stats WHERE date < %s", $date)); 40 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_referrer_stats WHERE date < %s", $date)); 41 | 42 | self::delete_orphaned_referrer_urls(); 43 | self::delete_orphaned_paths(); 44 | self::delete_blocked_referrers(); 45 | } 46 | 47 | protected static function delete_orphaned_referrer_urls(): void 48 | { 49 | /** @var \wpdb $wpdb */ 50 | global $wpdb; 51 | 52 | // delete unused referrer urls 53 | $results = $wpdb->get_results("SELECT id FROM {$wpdb->prefix}koko_analytics_referrer_urls WHERE id NOT IN (SELECT DISTINCT(id) FROM {$wpdb->prefix}koko_analytics_referrer_stats)"); 54 | 55 | // we explicitly delete the rows one-by-one here because the bulk with subquery approach we used before 56 | // would hang on certain MySQL installations (according to user reports) 57 | foreach ($results as $r) { 58 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_referrer_urls WHERE id = %d LIMIT 1", [$r->id])); 59 | } 60 | } 61 | 62 | protected static function delete_orphaned_paths(): void 63 | { 64 | /** @var \wpdb $wpdb */ 65 | global $wpdb; 66 | 67 | $results = $wpdb->get_results("SELECT id FROM {$wpdb->prefix}koko_analytics_paths WHERE id NOT IN (SELECT DISTINCT(path_id) FROM {$wpdb->prefix}koko_analytics_post_stats)"); 68 | 69 | // we explicitly delete the rows one-by-one here because the bulk with subquery approach we used before 70 | // would hang on certain MySQL installations (according to user reports) 71 | foreach ($results as $r) { 72 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_paths WHERE id = %d LIMIT 1", [$r->id])); 73 | } 74 | } 75 | 76 | protected static function delete_blocked_referrers(): void 77 | { 78 | global $wpdb; 79 | 80 | $blocklist = new Blocklist(); 81 | $list = array_merge($blocklist->read(), apply_filters('koko_analytics_referrer_blocklist', [])); 82 | $count = count($list); 83 | 84 | // process list in batches of 100 85 | for ($offset = 0; $offset < $count; $offset += 100) { 86 | $chunk = array_slice($list, $offset, 100); 87 | $chunk = array_map(function ($v) use ($wpdb) { 88 | return $wpdb->esc_like("%{$v}%"); 89 | }, $chunk); 90 | 91 | $where = str_repeat("url LIKE %s OR ", count($chunk)); 92 | $where = substr($where, 0, strlen($where) - 4); 93 | 94 | $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}koko_analytics_referrer_urls WHERE {$where}", $chunk)); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Koko Analytics for WordPress 2 | =========== 3 | [![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://raw.githubusercontent.com/ibericode/koko-analytics/master/LICENSE) 4 | [![Active installs](https://img.shields.io/wordpress/plugin/installs/koko-analytics.svg)](https://wordpress.org/plugins/koko-analytics/advanced/) 5 | [![Rating](https://img.shields.io/wordpress/plugin/r/koko-analytics.svg)](https://wordpress.org/support/plugin/koko-analytics/reviews/) 6 | [![Lighthouse performance score](https://raw.githubusercontent.com/ibericode/koko-analytics/master/assets/src/github/lighthouse_performance.svg)](https://pagespeed.web.dev/analysis/https-www-kokoanalytics-com/e8d4epi2jf?form_factor=mobile) 7 | 8 | **[Koko Analytics](https://www.kokoanalytics.com/) is a fast, privacy-friendly, open-source analytics plugin for WordPress — no cookies, no tracking, no external services.** 9 | 10 |
11 | Screenshot of the Koko Analytics dashboard 12 |
Screenshot of the Koko Analytics dashboard. You can view a live demo here.
13 |
14 | 15 | --- 16 | 17 | ## Why Koko Analytics? 18 | 19 | Koko Analytics is a simple replacement for Google Analytics that: 20 | 21 | - Works out-of-the-box (no setup wizard or API keys) 22 | - Stores all data on your server 23 | - Tracks only aggregated counts, never personal data 24 | - Respects GDPR and CCPA by design 25 | - Loads lightning-fast with a script under 500 bytes 26 | 27 | [See All Features →](https://www.kokoanalytics.com/features/) 28 | 29 | --- 30 | 31 | ### Koko Analytics Pro (optional add-on) 32 | 33 | Upgrade for advanced insights while keeping your visitors’ privacy intact: 34 | 35 | - 🌍 [Geo-location](https://www.kokoanalytics.com/features/geo-location/) 36 | - 🔗 [Event Tracking](https://www.kokoanalytics.com/features/custom-event-tracking/) 37 | - 📨 [Email Reports](https://www.kokoanalytics.com/features/email-reports/) 38 | - 🛠 [CSV Export](https://www.kokoanalytics.com/features/csv-export/) 39 | - 📈 [Pageviews Column in WP Admin](https://www.kokoanalytics.com/features/pageviews-column/) 40 | - 📊 [Admin Bar stats](https://www.kokoanalytics.com/features/admin-bar/) 41 | - 🚨 [Traffic Spike Notifications](https://www.kokoanalytics.com/features/traffic-spike-notifications/) 42 | 43 | [Get Koko Analytics Pro →](https://www.kokoanalytics.com/pricing/) 44 | 45 | --- 46 | 47 | ## Quick Start 48 | 49 | ### Requirements 50 | 51 | - WordPress 6.0+ 52 | - PHP 7.4+ (latest recommended) 53 | 54 | ### Install from WordPress.org 55 | 56 | 1. In your WordPress dashboard, go to **Plugins → Add New** 57 | 1. Search for “Koko Analytics” 58 | 1. Click **Install** and **Activate** — stats start recording immediately 59 | 60 | ### Install from GitHub (latest development version) 61 | 62 | ```sh 63 | git clone git@github.com:ibericode/koko-analytics.git wp-content/plugins/koko-analytics 64 | cd wp-content/plugins/koko-analytics 65 | composer install 66 | npm install 67 | npm run build 68 | ``` 69 | 70 | --- 71 | 72 | ## Usage 73 | 74 | Once activated, visit Dashboard → Analytics in WordPress to view your stats. 75 | 76 | See our [Knowledge Base](https://www.kokoanalytics.com/kb/) for advanced configuration, REST API usage, and code snippets. 77 | 78 | --- 79 | 80 | ## Contributing 81 | 82 | Koko Analytics is open source and community-driven. You can: 83 | 84 | - ⭐ Star this repo 85 | - 🗣 [Suggest & vote on features](https://github.com/ibericode/koko-analytics/discussions) 86 | - 🌍 [Translate the plugin](https://translate.wordpress.org/projects/wp-plugins/koko-analytics/stable/) 87 | - 📝 Write about it on your blog or social media 88 | - 💖 [Upgrade to Koko Analytics Pro](https://www.kokoanalytics.com/pricing/) to fund development 89 | 90 | --- 91 | 92 | ## License 93 | 94 | GNU General Public License v3.0 95 | -------------------------------------------------------------------------------- /src/Script_Loader.php: -------------------------------------------------------------------------------- 1 | ' . PHP_EOL; 27 | wp_print_inline_script_tag(file_get_contents(KOKO_ANALYTICS_PLUGIN_DIR . '/assets/dist/js/script.js')); 28 | echo PHP_EOL; 29 | } 30 | 31 | /** 32 | * Returns the internal ID of the page or post that is being shown. 33 | * 34 | * If page is not a singular object, the function returns 0 if it is the front page (from Settings) 35 | */ 36 | private static function get_post_id(): int 37 | { 38 | if (is_singular()) { 39 | return get_queried_object_id(); 40 | } 41 | 42 | return 0; 43 | } 44 | 45 | private static function get_tracker_url(): string 46 | { 47 | // People can create their own endpoint and define it through this constant 48 | if (\defined('KOKO_ANALYTICS_CUSTOM_ENDPOINT') && KOKO_ANALYTICS_CUSTOM_ENDPOINT) { 49 | // custom custom endpoint 50 | return site_url(KOKO_ANALYTICS_CUSTOM_ENDPOINT); 51 | } elseif (using_custom_endpoint()) { 52 | // default custom endpoint 53 | return site_url('/koko-analytics-collect.php'); 54 | } 55 | 56 | // default URL (which includes WordPress) 57 | return admin_url('admin-ajax.php?action=koko_analytics_collect'); 58 | } 59 | 60 | public static function get_request_path(): string 61 | { 62 | return Normalizer::path(trim($_SERVER["REQUEST_URI"] ?? '')); 63 | } 64 | 65 | public static function print_js_object() 66 | { 67 | $settings = get_settings(); 68 | $script_config = [ 69 | // the URL of the tracking endpoint 70 | 'url' => self::get_tracker_url(), 71 | 72 | // root URL of site 73 | 'site_url' => get_home_url(), 74 | 75 | 'post_id' => self::get_post_id(), 76 | 'path' => self::get_request_path(), 77 | 78 | // tracking method to use (passed to endpoint) 79 | 'method' => $settings['tracking_method'], 80 | 81 | // for backwards compatibility with older versions 82 | // some users set this value from other client-side scripts, ie cookie consent banners 83 | // if true, takes priority of the method property defined above 84 | 'use_cookie' => $settings['tracking_method'] === 'cookie', 85 | ]; 86 | $data = 'window.koko_analytics = ' . \json_encode($script_config) . ';'; 87 | wp_print_inline_script_tag($data); 88 | } 89 | 90 | public static function print_amp_analytics_tag() 91 | { 92 | $settings = get_settings(); 93 | $data = [ 94 | 'm' => $settings['tracking_method'][0], 95 | 'po' => self::get_post_id(), 96 | 'pa' => self::get_request_path(), 97 | ]; 98 | $url = add_query_arg($data, self::get_tracker_url()); 99 | $config = [ 100 | 'requests' => [ 101 | 'pageview' => $url, 102 | ], 103 | 'triggers' => [ 104 | 'trackPageview' => [ 105 | 'on' => 'visible', 106 | 'request' => 'pageview', 107 | ], 108 | ], 109 | ]; 110 | 111 | echo ''; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Resources/views/settings/data.php: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 | 5 | 6 |
7 | 8 | 9 |

10 |
11 | 12 |
13 | 14 |
15 |
16 | 17 |
18 |

19 |

20 |
21 | 22 | 23 | 24 |
25 | 26 |
27 |
28 | 29 |
30 |
31 |
32 | 33 | 34 |
35 |

36 |

37 | 38 |
    39 |
  • 40 |
  • 41 |
42 |
43 | 44 | 45 |
46 |

47 |

48 |
49 | 50 | 51 | 52 | 53 |
54 |
55 | 56 |
57 |

58 |

59 |
60 | 61 | 62 | 63 | 64 |
65 |
66 | -------------------------------------------------------------------------------- /src/Resources/views/settings-page.php: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 9 |

Koko Analytics logo

10 | 11 |
12 | 13 |
14 |
15 |
    16 |
  • 17 |
  • 18 |
  • 19 |
  • 20 |
  • Data
  • 21 | 22 |
  • 23 | 24 |
  • 25 |
26 |
27 |
28 |
29 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 47 | 48 | 52 | 53 | 54 |
55 | 56 |
57 |
58 |
59 |
60 | 61 | 62 |
63 | 64 | -------------------------------------------------------------------------------- /src/Admin/Pages.php: -------------------------------------------------------------------------------- 1 |

'; 28 | echo esc_html__('There seems to be an issue with your site\'s WP Cron configuration that prevents Koko Analytics from automatically processing your statistics.', 'koko-analytics'); 29 | echo ' '; 30 | echo esc_html__('If you\'re not sure what this is about, please ask your webhost to look into this.', 'koko-analytics'); 31 | echo '

'; 32 | } 33 | 34 | // determine whether buffer file is writable 35 | $buffer_filename = get_buffer_filename(); 36 | $buffer_dirname = dirname($buffer_filename); 37 | $is_buffer_dir_writable = wp_mkdir_p($buffer_dirname) && is_writable($buffer_dirname); 38 | 39 | if (false === $is_buffer_dir_writable) { 40 | echo '

'; 41 | echo wp_kses(\sprintf(__('Koko Analytics is unable to write to the %s directory. Please update the file permissions so that your web server can write to it.', 'koko-analytics'), $buffer_dirname), ['code' => []]); 42 | echo '

'; 43 | } 44 | 45 | $dashboard = new Dashboard(); 46 | $dashboard->show(); 47 | } 48 | 49 | public static function show_settings_page(): void 50 | { 51 | if (!current_user_can('manage_koko_analytics')) { 52 | return; 53 | } 54 | 55 | $allowed_tabs = [ 56 | 'tracking', 57 | 'dashboard', 58 | 'events', 59 | 'email-reports', 60 | 'data', 61 | 'performance', 62 | 'help', 63 | 'jetpack_importer', 64 | 'plausible_importer', 65 | ]; 66 | $active_tab = isset($_GET['tab']) && in_array($_GET['tab'], $allowed_tabs) ? $_GET['tab'] : 'tracking'; 67 | 68 | $settings = get_settings(); 69 | $using_custom_endpoint = using_custom_endpoint(); 70 | $user_roles = self::get_available_roles(); 71 | $date_presets = (new Dashboard())->get_date_presets(); 72 | $public_dashboard_url = Router::url('dashboard-standalone'); 73 | 74 | require KOKO_ANALYTICS_PLUGIN_DIR . '/src/Resources/views/settings-page.php'; 75 | } 76 | 77 | 78 | private static function get_available_roles(): array 79 | { 80 | $roles = []; 81 | foreach (wp_roles()->roles as $key => $role) { 82 | $roles[$key] = $role['name']; 83 | } 84 | return $roles; 85 | } 86 | 87 | /** 88 | * Checks to see if the cron event is correctly scheduled and running periodically 89 | * If the cron event is somehow not scheduled, this will schedule it again. 90 | */ 91 | private static function is_cron_event_working(): bool 92 | { 93 | // Always return true on localhost / dev-ish environments 94 | $site_url = get_site_url(); 95 | $parts = parse_url($site_url); 96 | if (!is_array($parts) || !empty($parts['port']) || str_contains($parts['host'], 'localhost') || str_contains($parts['host'], 'local')) { 97 | return true; 98 | } 99 | 100 | // detect issues with WP Cron event not running 101 | // it should run every minute, so if it didn't run in 40 minutes there is most likely something wrong 102 | // some host run WP Cron only once per 15 minutes, so that is probably the lower bound of this check 103 | $next_scheduled = wp_next_scheduled('koko_analytics_aggregate_stats'); 104 | if ($next_scheduled === false) { 105 | // if the event does not appear in scheduled event list at all 106 | // schedule it now 107 | wp_schedule_event(time() + 60, 'koko_analytics_stats_aggregate_interval', 'koko_analytics_aggregate_stats'); 108 | return true; 109 | } 110 | 111 | return $next_scheduled !== false && $next_scheduled > (time() - 40 * 60); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/Resources/views/settings/tracking.php: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 | 5 | 6 | 7 |
8 |
9 | 10 |
    11 |
  • 12 | 16 | 17 |
  • 18 |
  • 19 | 23 |
  • 24 |
  • 28 |
  • 29 |
30 | 31 |

cookie vs. cookieless tracking.', 'koko-analytics'), ['a' => ['href' => true]]), 'https://www.kokoanalytics.com/kb/cookie-vs-cookieless-tracking-methods'); ?>

32 |
33 |
34 |
35 | 36 | 47 |

48 | 49 | 50 |

51 |
52 | 53 |
54 | 55 | ', max(4, count($settings['exclude_ip_addresses']))); 57 | echo esc_textarea(join(PHP_EOL, $settings['exclude_ip_addresses'])); 58 | echo ''; 59 | ?> 60 |

61 | 62 | 63 | 64 | 65 | 66 | ' . esc_html(\KokoAnalytics\get_client_ip()) . ''); ?> 67 | 68 |

69 |
70 | 71 | 72 | 73 |
74 | 75 |
76 |
77 | -------------------------------------------------------------------------------- /src/Resources/views/settings/jetpack_importer.php: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 14 | 15 | 16 |

17 |

18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | 35 | 36 | 40 | 41 | 42 | 43 | 44 | 49 | 50 | 51 | 52 | 53 | 58 | 59 | 60 | 61 | 62 | 67 | 68 |
29 | 30 |

', ''); ?>

31 |
37 | 38 |

39 |
45 | 46 |

47 | 48 |
54 | 55 |

56 | 57 |
63 | 64 |

65 | 66 |
69 | 70 |

71 | 72 | 73 |

74 | 75 |

76 | 77 |

78 |
79 | 80 |
81 |

82 |

83 |
84 | -------------------------------------------------------------------------------- /src/Import/Plausible_Importer.php: -------------------------------------------------------------------------------- 1 | = 3 && $header[0] == 'date' && $header[1] == 'visitors' && $header[2] == 'pageviews') { 41 | self::import_site_stats($fh, $header, $date_start, $date_end); 42 | } elseif (count($header) >= 4 && $header[0] == 'date' && $header[1] == 'hostname' && $header[2] == 'page' && $header[4] == 'visitors' && $header[5] == 'pageviews') { 43 | self::import_page_stats($fh, $header, $date_start, $date_end); 44 | } elseif (count($header) >= 3 && $header[0] == 'date' && $header[1] == 'source' && $header[2] == 'referrer') { 45 | self::import_referrer_stats($fh, $header, $date_start, $date_end); 46 | } else { 47 | throw new Exception("Sorry, that file is not supported."); 48 | } 49 | } catch (Exception $e) { 50 | static::redirect_with_error(static::get_admin_url(), $e->getMessage()); 51 | } 52 | 53 | fclose($fh); 54 | 55 | static::redirect(static::get_admin_url(), ['success' => 1]); 56 | } 57 | 58 | private static function import_site_stats($fh, array $headers, string $date_start, string $date_end): void 59 | { 60 | /** @var wpdb $wpdb */ 61 | global $wpdb; 62 | 63 | while ($row = fgetcsv($fh, 1024, ',', '"', '')) { 64 | $row = array_combine($headers, $row); 65 | 66 | // skip rows outside of date range 67 | if ($row['date'] < $date_start || $row['date'] > $date_end) { 68 | continue; 69 | } 70 | 71 | // update site stats 72 | $query = $wpdb->prepare("INSERT INTO {$wpdb->prefix}koko_analytics_site_stats(date, visitors, pageviews) VALUES (%s, %d, %d) ON DUPLICATE KEY UPDATE visitors = visitors + VALUES(visitors), pageviews = pageviews + VALUES(pageviews)", [$row['date'], $row['visitors'], $row['pageviews']]); 73 | $wpdb->query($query); 74 | if ($wpdb->last_error !== '') { 75 | throw new Exception(__("A database error occurred: ", 'koko-analytics') . " {$wpdb->last_error}"); 76 | } 77 | } 78 | } 79 | 80 | private static function import_page_stats($fh, array $headers, string $date_start, string $date_end): void 81 | { 82 | // "date","hostname","page","visits","visitors","pageviews","total_scroll_depth","total_scroll_depth_visits","total_time_on_page","total_time_on_page_visits" 83 | 84 | $rows = []; 85 | while ($row = fgetcsv($fh, 1024, ',', '"', '')) { 86 | $row = array_combine($headers, $row); 87 | 88 | // skip rows outside of date range 89 | if ($row['date'] < $date_start || $row['date'] > $date_end) { 90 | continue; 91 | } 92 | 93 | // add to rows 94 | $rows[] = [$row['date'], $row['page'], 0, $row['visitors'], $row['pageviews']]; 95 | 96 | if (count($rows) >= 100) { 97 | static::bulk_insert_page_stats($rows); 98 | $rows = []; 99 | } 100 | } 101 | 102 | static::bulk_insert_page_stats($rows); 103 | } 104 | 105 | private static function import_referrer_stats($fh, array $headers, string $date_start, string $date_end): void 106 | { 107 | // "date","source","referrer","utm_source","utm_medium","utm_campaign","utm_content","utm_term","pageviews","visitors","visits","visit_duration","bounces" 108 | 109 | $rows = []; 110 | while ($row = fgetcsv($fh, 1024, ',', '"', '')) { 111 | $row = array_combine($headers, $row); 112 | 113 | // skip rows outside of date range 114 | if ($row['date'] < $date_start || $row['date'] > $date_end || empty($row['referrer'])) { 115 | continue; 116 | } 117 | 118 | // add to rows 119 | $rows[] = [$row['date'], $row['referrer'], $row['visitors'], $row['pageviews']]; 120 | 121 | if (count($rows) >= 100) { 122 | static::bulk_insert_referrer_stats($rows); 123 | $rows = []; 124 | } 125 | } 126 | 127 | static::bulk_insert_referrer_stats($rows); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/Normalizers/NormalizerTest.php: -------------------------------------------------------------------------------- 1 | '', 17 | '/' => '/', 18 | '/about/' => '/about/', 19 | '/koko/is/great/' => '/koko/is/great/', 20 | '/?p=100' => '/?p=100', 21 | '/?utm_source=source&utm_medium=medium&utm_campaign=campaign' => '/', 22 | '/?utm_source=source&utm_medium=medium&p=200&utm_campaign=campaign' => '/?p=200', 23 | '/?attachment_id=123' => '/?attachment_id=123', 24 | '/blog/2024/01/hello-world/amp/' => '/blog/2024/01/hello-world/', 25 | ]; 26 | 27 | foreach ($tests as $input => $output) { 28 | $this->assertEquals($output, Normalizer::path($input)); 29 | } 30 | } 31 | 32 | public function test_referrer(): void 33 | { 34 | $tests = [ 35 | '' => '', 36 | 'https://www.kokoanalytics.com' => 'kokoanalytics.com', 37 | 'https://dentalclinicwhatever.com' => 'dentalclinicwhatever.com', 38 | 'https://wordpress.org/plugins/koko-analytics/' => 'wordpress.org/plugins/koko-analytics/', 39 | 'https://pinterest.com/pin/foobar' => 'pinterest.com', 40 | 'https://www.pinterest.com' => 'pinterest.com', 41 | 'https://www.pinterest.com/pin/foobar' => 'pinterest.com', 42 | 'ios-app://429047995' => 'pinterest.com', 43 | 'https://www.google.com' => 'google.com', 44 | 'https://www.google.nl/url' => 'google.nl', 45 | 'https://www.google.nl/search' => 'google.nl', 46 | 'http://google.nl/search' => 'google.nl', 47 | 'https://www.google.co.uk/search' => 'google.co.uk', 48 | 'https://www.google.com/search' => 'google.com', 49 | 'android-app://com.google.android.googlequicksearchbox' => 'google.com', 50 | 'android-app://com.google.android.googlequicksearchbox/https/www.google.com' => 'google.com', 51 | 'android-app://com.www.google.android.googlequicksearchbox' => 'google.com', 52 | 'android-app://com.www.google.android.googlequicksearchbox/https/www.google.com' => 'google.com', 53 | 'android-app://com.www.google.android.gm' => 'google.com', 54 | 'https://bing.com' => 'bing.com', 55 | 'https://www.bing.com' => 'bing.com', 56 | 'https://www.bing.com/search' => 'bing.com', 57 | 'https://www.bing.com/url' => 'bing.com', 58 | 'android-app://com.facebook.katana' => 'facebook.com', 59 | 'android-app://m.facebook.com' => 'facebook.com', 60 | 'https://m.facebook.com' => 'facebook.com', 61 | 'https://m.facebook.com/profile/whatever' => 'facebook.com', 62 | 'https://l.facebook.com' => 'facebook.com', 63 | 'https://l.facebook.com/l.php' => 'facebook.com', 64 | 'https://lfacebook.com' => 'facebook.com', // Don't know what's up with this domain 65 | 'https://de-de.facebook.com' => 'facebook.com', 66 | 'https://www.facebook.com' => 'facebook.com', 67 | 'https://l.instagram.com' => 'instagram.com', 68 | 'https://instagram.com' => 'instagram.com', 69 | 'https://www.ecosia.org/search' => 'ecosia.org', 70 | 'https://www.linkedin.com/feed' => 'linkedin.com', 71 | 'https://www.linkedin.com/feed/' => 'linkedin.com', 72 | 'https://www.linkedin.com/feed/update/urn:li:activity:6620280880285921280' => 'linkedin.com', 73 | 'https://www.baidu.com/link' => 'baidu.com', 74 | 'https://m.baidu.com/from=844b/bd_page_type=1/ssid=98c26c6f6e676d65697869620b/uid=0/pu=usm%402%2Csz%40320_1001%2Cta%40iphone_2_9.0_24_79.0/baiduid=B24A174BB75A8A37CEA414106EC583CB/w=0_10_/t=iphone/l=1/tc' => 'baidu.com', 75 | 'https://yandex.ru/clck/jsredir' => 'yandex.ru', 76 | 'https://yandex.ru/search' => 'yandex.ru', 77 | 'https://search.yahoo.com/search;_ylt=AwrJ62D7vO9hhigARMpXNyoA;_ylu=Y29sbwNiZjEEcG9zAzEEdnRpZAMEc2VjA3BhZ2luYXRpb24-?p=danny+van+kooten' => 'search.yahoo.com', 78 | 'https://r.search.yahoo.com/search;_ylt=AwrJ62D7vO9hhigARMpXNyoA;_ylu=Y29sbwNiZjEEcG9zAzEEdnRpZAMEc2VjA3BhZ2luYXRpb24-?p=danny+van+kooten' => 'search.yahoo.com', 79 | 'https://r.search.yahoo.com/_ylt=AwrJ3s8QPIlhnGgADxAYAopQ;_ylu=c2VjA3NyBHNsawNpbWcEb2lkA2U2ZTY3ZmExZDUzNDAwYmU5MjAzYTYxN2U1ZTI5YTQ2BGdwb3MDMTcEaXQDYmluZw--/RV=2/RE=1636412560/RO=11/RU=http%3a%2f%2fvankootenarchitectuur.nl%2fverbouwing-kerkzaal-taborkerk%2f' => 'search.yahoo.com', 80 | 'https://out.reddit.com' => 'reddit.com', 81 | 'https://new.reddit.com' => 'reddit.com', 82 | 'https://old.reddit.com' => 'reddit.com', 83 | 'https://www.reddit.com' => 'reddit.com', 84 | 'https://m.reddit.com' => 'reddit.com', 85 | 'https://6gg78.r.ah.d.sendibm4.com/mk/cl/f/sugrxasd218e287' => 'brevo.com', 86 | 'https://6gg78.r.ah.d.sendibt1.com/mk/cl/f/sugrxasd218e287' => 'brevo.com', 87 | 88 | 'https://wordpress.org' => 'wordpress.org', 89 | 'https://wordpress.org/' => 'wordpress.org', 90 | 'https://wordpress.org/?utm_source=duckduckgo' => 'wordpress.org', 91 | 'https://wordpress.org/?page_id=500&utm_source=duckduckgo' => 'wordpress.org', 92 | 'https://wordpress.org/?utm_source=duckduckgo&p=500&cat=cars&product=toyota-yaris' => 'wordpress.org', 93 | 'https://wordpress.org/?foo=bar&p=500&utm_source=duckduckgo#utm_medium=link' => 'wordpress.org', 94 | 'https://wordpress.org/#foo=bar&bar=foo' => 'wordpress.org', 95 | ]; 96 | 97 | foreach ($tests as $input => $output) { 98 | $this->assertEquals($output, Normalizer::referrer($input), $input); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Chart_View.php: -------------------------------------------------------------------------------- 1 | 0 ? 100.0 / (float) $n : 100.0; 17 | $y_max = 0; 18 | foreach ($data as $tick) { 19 | $y_max = max($y_max, $tick->pageviews); 20 | } 21 | $y_max_nice = $this->get_magnitude($y_max); 22 | $padding_top = 6; 23 | $padding_bottom = 24; 24 | $padding_left = 4 + strlen(number_format_i18n($y_max_nice)) * 8; 25 | $inner_height = $height - $padding_top - $padding_bottom; 26 | $height_modifier = $y_max_nice > 0 ? $inner_height / $y_max_nice : 1; 27 | $dateFormat = (string) get_option('date_format', 'Y-m-d'); 28 | $daysDiff = abs($dateEnd->diff($dateStart)->days); 29 | $timezone = wp_timezone(); 30 | ?> 31 |
32 | 7) { ?> 33 |
34 | 35 | 36 | 37 | 38 | 39 | 31) { 40 | ?> 42 |
43 | 44 | 45 | 46 | 0 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | getTimestamp()); ?> 55 | getTimestamp()); ?> 56 | 57 | 70 | 71 | 87 |
100000) { 97 | return (int) ceil($n / 10000.0) * 10000; 98 | } 99 | 100 | $e = floor(log10($n)); 101 | $pow = pow(10, $e); 102 | return (int) (ceil($n / $pow) * $pow); 103 | } 104 | } 105 | --------------------------------------------------------------------------------