When you have a blog, an eCommerce website, or even a giant social media website like Facebook or Twitter, then you certainly want to know how many times people view, like, comment, order, etc. You want to know how often your visitors do things or take certain actions. These metrics are so important that they can even drive people insane. We have globally known companies, like Alexa and Google Adsense built specifically on these metrics.
Have a look at what we're going to build HERE
In a previous post, I made mention how I do not agree with Google Adsense metrics. Billions are poured in advertising every day and that too depends on those metrics. Most companies would require these numbers before they partner with you.
Many lives have been negatively affected due to metrics such as low likes on their posts on various social media platforms. There are reasons for my skepticism about - decisions that decide the nature of a count. You will understand what this means in a moment.
The question is, how do we count?
For a human, it's quite easy to identify when we visit a webpage and could count the number of distinctive actions we take. We can also measure our intentions, which ones are true and purposeful, and those that are not. For example, it would be easy for Facebook to know how many people commented on a post or how many people liked it. But when all the posts are piled on top of each other and the viewer scrolls infinitely, how do you accurately determine if the human visited sees/saw the posts?
For computers to find out how often or how many times a human takes an action can become a difficult challenge, especially when it comes to visiting web pages. It's not easy to keep track of things over various web requests because the HTTP protocol is stateless. For example, how do you detect a new or old visitor to your site? How do you trace pages they visit? How do you count unique visits to each page, and how do you know if the visitor has left the page and came back or just remained idle and returned? And if that was the case, would you count a new visit or assume the old visit continues? These are few challenges one must always face when it comes to these metrics.
In this article, I would like to take you through two simple scenarios where we count the number of views of articles on a website. I am going to use the example of Lancecourse. I will show you how I count my views. The purpose of this tutorial is:
- To help you understand the complexity related to website metrics
- To teach you how to find intel in data
- To teach you some basic principles in PHP such as Object-Oriented Programming, code organization, etc.
- And, to make you question metrics and allow you to find some peace in your heart when you have less social influence
Of Lancecourse I count everything. This has been going on since phpocean's era. My blog posts, tutorial visits, and course chapters, and much more are counted. Among them, there are two main metrics that I take very seriously: the post opens
and views
.
Open: I consider has "open" every time the URL is hit.
View: A "view" is when the user doesn't bounce and spends some time at the URL.
The reason why I use these two metrics is "bots". You can notice how I emphasized the "user" in the "views" definition above. We have human and robot visitors. Robots tend to be fixed and behave almost spontaneously. On contrary, humans tend to spend variable time. Humans are unpredictable and behave quasi-randomly. The "views" help to narrow the interest rate of a visitor. Over time I have developed two ways to do some of these tasks which I have decided to share a simple application with you in this tutorial.
Design
Solution A
I use to define the start-time and an end-time for every Open, then I tie it up to the user's IP. Based on the IP address(which is easly spoofed), I track how long a user spends on a URI. Following is the process:
* User hits a post's URI
* Is user's IP already in the list of vistors?
* If No:
- Save IP and page URI or query string
- Save timestamp of visit of page
- Increment post's views
* If Yes:
- Has he visited this page before?
- If No:
- Save URL
- Save timestamp of visit of page
- Increment post's views
- If Yes:
- Is `current_time - timestamp_of_visit >= a_set_duration` ?
- If Yes:
- Update timestamp of visit to current time
- Increment post's views
- If No:
- Do nothing
Limitations
The problem with that solution is that I have to manually set the "how-long" or the a_set_duration. Plus, this requires me to have a table to store every unique IP and the URLs visited.
- Too many requests to the database can affect the application's efficiency
- The duration set to check the user session time is static and doubtful
- Saving IPs and URLs are not efficient
- We can lose real visits in between the set duration that determines new visits
- Has to develop a king of garbage collection system to clear the table every time there is a new visitor. Every time a user visits, I will go into the database and check all pages whose last-update is older than a day or a certain duration. Because when people leave their records to remain and that needs to be cleared. Otherwise, when they come back the system will not see them as new visitors. Besides, their IP addresses change often.
Advantages
The solution is not fully bad, there is some advantage that we can gain from it:
- It provides a lot of data on the visitor such as their IP
- We can use the same system to know how many people are currently on the site(online visitors)
- It can also help us know in real time where users visit from
- It can be used to enforce a certain time before an action can be taken
- It can be used to count normal page views, which is not necessary a blog post
Solution B
I have recently decided to improve that metric by adopting a session-based counting and detection. In this design, I created a different model(counting table) that records the views
and opens
of each post. This requires us to have another table to keep track of the views and opens for each post. This time around when a user visits a URL the process goes like this:
- Check if there is any record in the counting table
* If yes:
- Update the raw by incrementing the opens
- Check if there is any session associated with this visitor and this page?
- If yes:
- Take current time
- Calculate the time required to read the post. Calculated by estimating the reading time.
- Determine the elapsed time
- Is elapsed time greater than or equals to the time required to read post
- Yes:
- Increment post views as well
- Refresh start time with the current time
- If no:
- Update raw by incrementing the views
- Save in session the visit time
* If no:
- Increment the opens
- Increment the views
- Record a raw in the counting table
You can see that in this model, the opens are always incremented every time the URL is loaded.
Advantages
Some of the advantages of this solution are:
- The garbage collection is done automatically by the user's browser by simply expiring sessions.
- We leverage the browser's default session life-span
- The
a_set_duration
used is dynamically calculated based on the size of the content of the article - Machine learning can be used over time to get
a_set_duration
to be as close as possible to the actual human behavior - The solution is short and faster.
- It's more suitable for dynamic content
- It does fewer queries to the database
- It provides us with more data and intel
- Can be used to estimate the reading time of articles
Limitations
- The main limitation in this design is that the session is per browser. Different browsers will record a view count. This could be coupled with the user's IP to identify unique user visits.
- Also, setting the reading speed at a constant number infers that everyone reads at the same speed which is not true.
- Not suitable for static content which is part of the page structure
Implementation
I am going to use PHP to code these two solutions. For both cases, we will need a table to keep a track of our articles(pages). We have three pages in the whole project. Every time you visit one of them, it will display its metrics. So, go ahead and create a database and the tables that we will need as follows:
CREATE DATABASE IF NOT EXISTS `counting`;
CREATE TABLE IF NOT EXISTS `articles` (around `id` int(11) NOT NULL AUTO_INCREMENT,around `slug` varchar(255) NOT NULL,around `text` text NOT NULL,around `views` bigint(20) NOT NULL DEFAULT '0',around `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,around `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,around PRIMARY KEY (`id`),around UNIQUE KEY `slug` (`slug`)around) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=latin1;
CREATE TABLE IF NOT EXISTS `opens_views` (around `id` int(11) NOT NULL AUTO_INCREMENT,around `article_id` bigint(20) NOT NULL,around `opens` bigint(20) NOT NULL DEFAULT '0',around `views` bigint(20) NOT NULL DEFAULT '0',around PRIMARY KEY (`id`)around) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=latin1;
CREATE TABLE IF NOT EXISTS `visitors` (around `id` bigint(20) NOT NULL AUTO_INCREMENT,around `ipaddress` varchar(50) NOT NULL,around `url` varchar(120) NOT NULL,around `created_at` varchar(120) NOT NULL,around PRIMARY KEY (`id`)around) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=latin1;
INSERT INTO `articles` (`id`, `slug`, `text`, `views`, `created_at`, `updated_at`) VALUESaround (1, 'first-article-showing-view-counting', '<h1>This is a Lorem Ipsum Text</h1>\r\nThe text will serve as an example to find out how long it takes the system to marke a new view.\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce imperdiet venenatis dolor placerat efficitur. Phasellus ac tempus libero. Praesent mattis sem eget purus finibus lobortis. Nullam dictum eros eu luctus aliquam. Sed malesuada efficitur arcu ut ornare. Vivamus mollis augue vel nulla aliquet, ut condimentum turpis finibus. Quisque a urna eget lectus elementum viverra non nec risus. Integer lobortis ligula et gravida imperdiet. Aenean eget dictum magna, a cursus nunc. Donec tempor mauris sapien, vel ornare urna scelerisque a. Duis eu faucibus arcu. Sed semper, justo nec tincidunt ultricies, arcu augue sodales nunc, quis auctor lorem ipsum in nisl. Proin ornare ante vel sagittis luctus.\r\n\r\nCras magna justo, pretium nec rutrum sit amet, convallis et est. Aliquam rutrum enim vel dolor vulputate posuere. Nulla quis justo tempus, mollis libero non, interdum erat. Quisque consectetur ullamcorper orci at placerat. Ut vulputate scelerisque pretium. Nullam tempus eros ut turpis eleifend finibus. Pellentesque hendrerit mi a diam mattis, sit amet bibendum felis pellentesque. Nulla et dolor rutrum, gravida est quis, sagittis ipsum. Etiam et molestie quam, et placerat magna.\r\n\r\nNam dictum tempus nunc ac vulputate. Pellentesque nunc massa, pulvinar sed mauris at, convallis accumsan ex. Proin et augue lectus. Proin in erat dignissim, vulputate libero sed, euismod nulla. Quisque luctus condimentum tellus id egestas. Proin lacinia, risus vitae imperdiet congue, lectus eros consequat enim, non cursus leo velit vitae elit. Praesent aliquet, nulla vehicula aliquam maximus, ipsum tellus mattis lorem, vitae mollis ante sapien ac elit. Cras aliquam odio tellus.\r\n\r\nFusce ac pharetra mi, quis pulvinar quam. Ut ornare, purus vel dignissim lacinia, velit orci dignissim lorem, id efficitur nibh est et eros. Etiam consectetur augue ac elit posuere, eget convallis sapien venenatis. Donec et urna ullamcorper, laoreet tortor id, tincidunt enim. Vestibulum luctus in nibh at blandit. Phasellus tincidunt, metus at ullamcorper luctus, diam nibh varius nibh, rutrum facilisis metus velit faucibus nibh. Praesent quis condimentum diam. Nam sit amet mollis ex. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas non rutrum nulla. Mauris dictum ipsum eu sem mattis lobortis ac id nisl. Suspendisse ornare pulvinar ante et placerat. Phasellus id egestas dolor, varius placerat nulla. Vivamus orci quam, molestie et odio eu, mollis sollicitudin ligula.\r\n\r\nQuisque sem nunc, malesuada vitae faucibus sit amet, aliquet eget velit. Pellentesque sodales posuere risus, eu tempus orci ultrices sed. Suspendisse ut ante non ligula placerat tincidunt. Etiam vel erat vitae ante pellentesque elementum vitae nec augue. Nullam luctus nulla justo, ut volutpat purus feugiat eget. Praesent vel finibus sem. Maecenas ex justo, lacinia eu massa id, venenatis pretium lacus. Quisque fringilla sodales ligula. Sed facilisis lectus sed tortor congue, eu sagittis lorem sagittis. Mauris vel mauris at metus congue aliquet at a risus. Vivamus at maximus leo. Fusce sollicitudin velit ipsum, sed hendrerit risus iaculis et. In nec aliquam erat. Suspendisse ut tristique sem, dapibus ultrices metus. Maecenas at sapien faucibus, scelerisque magna vel, pretium lacus.\r\n', 5, '2021-04-10 11:08:50', '2021-04-10 11:08:51'),around (2, 'second-article-showing-view-counting-session', '<h1>This is a Lorem Ipsum Text for the second article</h1>\r\nThe text will serve as an example to find out how long it takes the system to marke a new view. This article contains less content. Observe how it could have more views compared to the first one.\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce imperdiet venenatis dolor placerat efficitur. Phasellus ac tempus libero. Praesent mattis sem eget purus finibus lobortis. Nullam dictum eros eu luctus aliquam. Sed malesuada efficitur arcu ut ornare. Vivamus mollis augue vel nulla aliquet, ut condimentum turpis finibus. Quisque a urna eget lectus elementum viverra non nec risus. Integer lobortis ligula et gravida imperdiet. Aenean eget dictum magna, a cursus nunc. Donec tempor mauris sapien, vel ornare urna scelerisque a. Duis eu faucibus arcu. Sed semper, justo nec tincidunt ultricies, arcu augue sodales nunc, quis auctor lorem ipsum in nisl. Proin ornare ante vel sagittis luctus.\r\n\r\nCras magna justo, pretium nec rutrum sit amet, convallis et est. Aliquam rutrum enim vel dolor vulputate posuere. Nulla quis justo tempus, mollis libero non, interdum erat. Quisque consectetur ullamcorper orci at placerat. Ut vulputate scelerisque pretium. Nullam tempus eros ut turpis eleifend finibus. Pellentesque hendrerit mi a diam mattis, sit amet bibendum felis pellentesque. Nulla et dolor rutrum, gravida est quis, sagittis ipsum. Etiam et molestie quam, et placerat magna.\r\n\r\nNam dictum tempus nunc ac vulputate. Pellentesque nunc massa, pulvinar sed mauris at, convallis accumsan ex. Proin et augue lectus. Proin in erat dignissim, vulputate libero sed, euismod nulla. Quisque luctus condimentum tellus id egestas. Proin lacinia, risus vitae imperdiet congue, lectus eros consequat enim, non cursus leo velit vitae elit. Praesent aliquet, nulla vehicula aliquam maximus, ipsum tellus mattis lorem, vitae mollis ante sapien ac elit. Cras aliquam odio tellus.\r\n\r\n', 3, '2021-04-18 09:16:13', '2021-04-18 09:16:13'),around (3, '/', '<h1>The Homepage</h1>\r\nThis is the home page of the system. This page contains a little text. This will affect how the views are counted. Almost every second.\r\nTake your time to explore this metrics very well and see how the behave.\r\n\r\nSee how numbers speak to us <3', 18, '2021-04-12 16:43:17', '2021-04-12 16:43:18');around
Since I want to be able to show the behavior of the two solutions on every page I needed to code them in the same project. But they are disposed in independent classes that you can separate and use individually. In the not shell we have an index.php
file that serves as our starting point. Then we have a folder called includes
which contains the database settings(settings.php
) and a database connection class(database.php
), session manage utility class(session.php
), an abstract class(count.php
) containing common functions two both solutions, then two classes implementing each the two solutions(solution-a.php
and solution-b.php
). These two files are basically like the Object Mappers.
Along with the index.php
file we have two files init_a.php
and init_b.php
that contain the actual usage of each solution mentioned above. This allows us to separate the two implementations and also to be able to include them independently. The read.php
file displays dynamic content for our two example articles. It takes each article slug and fetches its content. Then a styles.css
file to help us style the pages a bit.
Visually this is how the project is structured.
How does it work?
It starts from the index.php
file. Once you start the project with your webserver the code in the index file is run. And this is how it works. It's separated in two main sections: the HTML part and the PHP part above the HTML as shown below:
index.php
<?php
// Bring in the session utility class
require_once 'includes/Session.php';
// Start session
new Session();
// Bring in Utilities(settings)
require_once 'includes/Settings.php';
// Bring in the database connection
require_once 'includes/Database.php';
// Bring in Base Counting class
require_once 'includes/Count.php';
// Bring in solution A imkplementation
require_once 'includes/Solution-a.php';
// Bring in solution B implementation
require_once 'includes/Solution-b.php';
// INIT
// Connect to database
// Database settings are set in includes/settings.php
$database_settings = $settings['database'];
$database = new Connection($database_settings['host'],
$database_settings['database'],
$database_settings['user'],
$database_settings['password']
);
$dbh = $database->open();
// SOLUTION A
require_once 'init_a.php';
// SOLUTION B
require_once 'init_b.php';
// Close the database connection
$database->close();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="description" content="Welcome to the group of self-reliant people.">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Counting article views | Demo - Lancecourse</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<!-- Alert box in case of error -->
<?php if( Session::get('alert') ): ?>
<div class="alert">
<?= Session::get('alert') ?>
</div>
<?php endif; Session::remove('alert') ?>
<!-- Info box in case of info -->
<?php if( Session::get('info') ): ?>
<div class="info">
<?= Session::get('info') ?>
</div>
<?php endif; Session::remove('info') ?>
<!-- B Alert box in case of error -->
<?php if( Session::get('alert_b') ): ?>
<div class="alert">
<?= Session::get('alert_b') ?>
</div>
<?php endif; Session::remove('alert_b') ?>
<!-- B Info box in case of info -->
<?php if( Session::get('info_b') ): ?>
<div class="info">
<?= Session::get('info_b') ?>
</div>
<?php endif; Session::remove('info_b') ?>
<div class="container">
<h1>Counting views</h1>
<h3>On this page</h3>
<section>
<div class="solution_a">
<h4>Solution A</h4>
<ul>
<li>Your IP: <em><?= $visitor_ip ?></em></li>
<li>URL: <em><?= $url ?></em></li>
<li>Start: <em><?= isset($visited_url->created_at) ? $visited_url->created_at: $start_time ?></em></li>
<li>Set Duration: <em><?= $solution_a_sesstings['session_duration'] ?> seconds</em></li>
<li>Current Timestamp: <em><?= $start_time ?></em></li>
<li>Elapsed Time: <em><?= $elapsed_time ?> seconds</em> </li>
<li>Garbage collection period: <em><?= $period ?> seconds</em></li>
<li>Current_timestamp - Start >= set_duration:
<em>
<?php if( $elapsed_time >= $solution_a_sesstings['session_duration'] ): echo "True"; ?>
<?php else: echo "False"; ?>
<?php endif ?>
</em>
</li>
<li>Current Views: <em><?= $views ?> Views</em> </li>
</ul>
</div>
<div class="solution_b">
<h4>Solution B</h4>
<ul>
<li>URL: <em><?= $url ?></em></li>
<li>Opens: <em><?= isset($open_views->opens) ? $open_views->opens : 0 ?></em></li>
<li>Views: <em><?= isset($open_views->views) ? $open_views->views : 0 ?></em></li>
<li>o/r: <em><?= isset($open_views->views) && isset($open_views->opens) ? $open_views->opens / $open_views->views : 0 ?></em></li>
<li>Page Session: <em><?= isset($article->id) ? 'article_reading_'.$article->id : 'article_reading_' ?> -> <?= isset($article->id) ? Session::get('article_reading_'.$article->id) : 'null'?></em> </li>
<li>Avg. Reading speed: <em><?= $solution_b_sesstings['avg_reading_speed'] ?> words/minute</em></li>
<li>Text size: <em><?= isset($text_size) ? $text_size : 0 ?> words</em></li>
<li>Time to read article: <em><?= $time_to_read_article ?> seconds</em></li>
<li>Current Timestamp: <em><?= $b_start_time ?></em></li>
<li>Elapsed Time: <em><?= $elapsed_time_reading ?></em></li>
<li>Elapsed_time_reading >= Time_to_read: <em><?= $elapsed_time_reading >= $time_to_read_article ? 'True':'False' ?></em></li>
</ul>
</div>
</section>
<small><em><?= $time_to_read_article / 60 ?> minute read</em> </small>
<section>
<h1>The Homepage</h1>
<p>This is the home page of the system. This page contains a little text. This will affect how the views are counted. Almost every second. <br>
Take your time to explore this metrics very well and see how the behave.</p>
<p>See how numbers speak to us <3</p>
</section>
<dl>
<dt>Example articles with different content sizes</dt>
<dd><a href="read.php?slug=first-article-showing-view-counting">The first article showing the counting</a></dd>
<dd><a href="read.php?slug=second-article-showing-view-counting-session">The Second article showing the counting</a></dd>
<dd><a href="" style="color: red">« Return to tutorial page</a></dd>
</dl>
</div>
<footer>
<p class="container"><?= date("Y") ?> © All rights reserved - Lancecourse.com - Created by <a href="">zooboole</a></p>
</footer>
</body>
</html>
What we do in this file is to include all the necessary files. The structure of the read.php
is the same as that of index.php
except that the content on read.php
is fetched dynamically. This style allows us to apply these metrics measurements to any page simply by bringing together the required files. Let me just show you the code of each file.
read.php
<?php
// Bring in the session utility class
require_once 'includes/Session.php'; new Session();
// Bring in Utilities(settings)
require_once 'includes/Settings.php';
// Bring in the database connection
require_once 'includes/Database.php';
// Bring in Base Counting class
require_once 'includes/Count.php';
// Bring in solution A imkplementation
require_once 'includes/Solution-a.php';
// Bring in solution B implementation
require_once 'includes/Solution-b.php';
// INIT
// Connect to database
// Database settings are set in includes/settings.php
$database_settings = $settings['database'];
$database = new Connection($database_settings['host'],
$database_settings['database'],
$database_settings['user'],
$database_settings['password']
);
$dbh = $database->open();
// SOLUTION A
require_once 'init_a.php';
// SOLUTION B
require_once 'init_b.php';
// Close the database connection
$database->close();
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="description" content="Welcome to the group of self-reliant people.">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Counting article views | Demo - Lancecourse</title>
<link rel="stylesheet" type="text/css" href="styles.css">
</head>
<body>
<!-- Alert box in case of error -->
<?php if( Session::get('alert') ): ?>
<div class="alert">
<?= Session::get('alert') ?>
</div>
<?php endif; Session::remove('alert') ?>
<!-- Info box in case of info -->
<?php if( Session::get('info') ): ?>
<div class="info">
<?= Session::get('info') ?>
</div>
<?php endif; Session::remove('info') ?>
<!-- B Alert box in case of error -->
<?php if( Session::get('alert_b') ): ?>
<div class="alert">
<?= Session::get('alert_b') ?>
</div>
<?php endif; Session::remove('alert_b') ?>
<!-- B Info box in case of info -->
<?php if( Session::get('info_b') ): ?>
<div class="info">
<?= Session::get('info_b') ?>
</div>
<?php endif; Session::remove('info_b') ?>
<div class="container">
<h3>On this page</h3>
<section>
<div class="solution_a">
<h4>Solution A</h4>
<ul>
<li>Your IP: <em><?= $visitor_ip ?></em></li>
<li>URL: <em><?= $url ?></em></li>
<li>Start: <em><?= isset($visited_url->created_at) ? $visited_url->created_at: $start_time ?></em></li>
<li>Set Duration: <em><?= $solution_a_sesstings['session_duration'] ?> seconds</em></li>
<li>Current Timestamp: <em><?= $start_time ?></em></li>
<li>Elapsed Time: <em><?= $elapsed_time ?> seconds</em> </li>
<li>Garbage collection period: <em><?= $period ?> seconds</em></li>
<li>Current_timestamp - Start >= set_duration:
<em>
<?php if( $elapsed_time >= $solution_a_sesstings['session_duration'] ): echo "True"; ?>
<?php else: echo "False"; ?>
<?php endif ?>
</em>
</li>
<li>Current Views: <em><?= $views ?> Views</em> </li>
</ul>
</div>
<div class="solution_b">
<h4>Solution B</h4>
<ul>
<li>URL: <em><?= $url ?></em></li>
<li>Opens: <em><?= isset($open_views->opens) ? $open_views->opens : 0 ?></em></li>
<li>Views: <em><?= isset($open_views->views) ? $open_views->views : 0 ?></em></li>
<li>o/r: <em><?= isset($open_views->views) && isset($open_views->opens) ? $open_views->opens / $open_views->views : 0 ?></em></li>
<li>Page Session: <em><?= isset($article->id) ? 'article_reading_'.$article->id : 'article_reading_' ?> -> <?= isset($article->id) ? Session::get('article_reading_'.$article->id) : 'null'?></em> </li>
<li>Elapsed Time: <em><?= $elapsed_time_reading ?></em></li>
<li>Avg. Reading speed: <em><?= $solution_b_sesstings['avg_reading_speed'] ?> words/minute</em></li>
<li>Text size: <em><?= isset($text_size) ? $text_size : 0 ?> words</em></li>
<li>Time to read article: <em><?= $time_to_read_article ?> seconds</em></li>
<li>Current Timestamp: <em><?= $b_start_time ?></em></li>
<li>Elapsed_time_reading >= Time_to_read: <em><?= $elapsed_time_reading >= $time_to_read_article ? 'True':'False' ?></em></li>
</ul>
</div>
</section><small><em><?= $time_to_read_article / 60 ?> minutes read</em> </small>
<section>
<?= nl2br($article->text) ?>
</section>
<dl>
<dt>Example articles with different content sizes</dt>
<dd><a href="read.php?slug=first-article-showing-view-counting">The first article showing the counting</a></dd>
<dd><a href="read.php?slug=second-article-showing-view-counting-session">The Second article showing the counting</a></dd>
<dd><a href="" style="color: red">« Return to tutorial page</a></dd>
<dd><a href="index.php" style="color: red">« Start page</a></dd>
</dl>
</div>
<footer>
<p class="container"><?= date("Y") ?> © All rights reserved - Lancecourse.com - Created by <a href="">zooboole</a></p>
</footer>
</body>
</html>
init_a.php
<?php
$solution_a_sesstings = $settings['solution_a'];
$visitor_a = new SolutionA($dbh);
// Do the garbage collection
// For the past $period in seconds
$period = $solution_a_sesstings['garbage_collect_period'];
if($visitor_a->collectGarbage($period)){
Session::set('info', 'A::Garbage collected!');
}
// Get visitor IP
$visitor_ip = $visitor_a->ip();
// Get url visited
$url = Session::makeUrl();
// Current timestamp
$start_time = time(); // current time
$visited_url = $url;
$elapsed_time = 0;
try {
// If visitor is new
if (!$old_visitor = $visitor_a->getAll($visitor_ip)) {
// Store new visitor
try {
$new_visitor_stored = $visitor_a->store( $visitor_ip, $url );
// After adding the visitor
// We increment the count for this $url
if($new_visitor_stored){
$visitor_a->addViewFor( $url );
Session::set('info', 'A::New count added to this page from your fresh visit!');
}
} catch (Exception $e) {
Session::set('alert', $e->getMessage());
}
}else{ // if visitor is old
// Get the the time they visited $url last
try {
$visited_url = $visitor_a->url($url, $visitor_ip);
if ($visited_url) {
// check time elapsed from first visit
$elapsed_time = time() - $visited_url->created_at ;
}else{
// Store new url for the same visitor
try {
$visitor_new_url = $visitor_a->store( $visitor_ip, $url );
// After adding the visitor
// We increment the count for this $url
if($visitor_new_url){
$visitor_a->addViewFor( $url );
Session::set('info', 'A::New url visited. New count added to this page from your fresh visit!');
}
} catch (Exception $e) {
Session::set('alert', $e->getMessage());
}
}
} catch (Exception $e) {
Session::set('alert', $e->getMessage());
}
// Visitor has been on the page for long enough
if( $elapsed_time >= $solution_a_sesstings['session_duration'] ){
// We count a new view again for $url
$visitor_a->addViewFor( $url );
Session::set('info', 'A:: New count added to this page! You visited this page more than once.');
// and we reset/refresh the visit time
try {
$visitor_a->refreshTime( $visited_url->url, $visitor_ip );
} catch (Exception $e) {
Session::set('alert', $e->getMessage());
}
}else{
// do nothing. Same visit session is going on.
}
}
} catch (Exception $e) {
Session::set('alert', $e->getMessage());
}
// Get $url views
$views = 0;
try {
$count = $visitor_a->articleViews($url);
$views = isset($count->views) ? $count->views : 0;
} catch (Exception $e) {
Session::set('alert', $e->getMessage());
}
init_b.php
<?php
// Solution B settings
$solution_b_sesstings = $settings['solution_b'];
// Instanciate the Solution B implementation class
$visitor_b = new SolutionB($dbh);
// Get visitor IP
// Can be used to identify unique visitors
// since sessions are not cross-browser
// $visitor_ip = $visitor_b->ip();
// Get url visited
$url = Session::makeUrl();
// Get the current time
$b_start_time = time();
// Current url
$visited_url = $url;
$time_to_read_article = 0;
$elapsed_time_reading = 0;
// Get the article being viewed
try {
$article = $visitor_b->getArticle($url);
} catch (Exception $e) {
Session::set('alert', 'B:: Error while getting article - '.$e->getMessage());
}
// Have we tracked this url before?
if( isset($article->id) && !$visitor_b->tracked($article->id) ){ //no
// Article not tracked
// Create article track Record
try {
// Store new track record
// Increments views to 1
// Increments opens to 1
$create_new_record = $visitor_b->store($article->id);
if ($create_new_record) {
// set user reading session
Session::set('article_reading_'.$article->id, $b_start_time);
// Notify
Session::set('info_b', 'B:: One view and open counts added');
}
} catch (Exception $e) {
Session::set('alert_b', 'B:: Error while creating track record - '.$e->getMessage());
}
}else{ // yes, Article is already being tracked
// Increment opens anyways
$visitor_b->increment($article->id, 'opens');
// Has the visitor been here already?
if (null == Session::get('article_reading_'.$article->id) ) { //visitor is new
// set user reading session now
Session::set('article_reading_'.$article->id, $b_start_time);
// This is a new view
$visitor_b->increment($article->id, 'views');
// Notify
Session::set('info_b', 'B:: One view and open counts added from new visitor on an article that is already being tracked.');
} else { // they opened the page again
// Notify
Session::set('info_b', 'B:: Opens incremented from returning visitor.');
// Calculate views of the article
$reading_speed = $solution_b_sesstings['avg_reading_speed']; // words per minute
// Text size
$text_size = str_word_count($article->text);
$time_to_read_article = ceil(($text_size / $reading_speed));
$time_to_read_article *= 60; //convert into seconds
$elapsed_time_reading = $b_start_time - Session::get('article_reading_'.$article->id);
// Increment Count
if ($elapsed_time_reading >= $time_to_read_article) {
$visitor_b->increment($article->id, 'views');
// reset user reading session to now
$b_start_time = time();
Session::set('article_reading_'.$article->id, $b_start_time);
// Notify
Session::set('info_b', 'B:: Article views incremented from old visit after reading time.');
}
}
}
// Get the final metrics
$open_views = $visitor_b->tracked($article->id);
styles.css
*{
box-sizing: border-box;
}
body{
color: #222222;
margin: 0;
padding: 0;
background: #efefef;
line-height: 1.65;
font-family: verdana;
font-size: 17px;
font-weight: 100;
overflow-x: hidden;
letter-spacing: -0.1px;
}
.container{
width: 90%;
margin: 0 auto;
}
@media screen and (min-width: 768px){
.container{
width: 70%;
margin: 0 auto;
}
}
.alert{
background-color: orange;
color: black;
text-align: center;
padding: 5px;
margin-bottom: 2rem;
}
.info{
background-color: blue;
color: white;
text-align: center;
padding: 5px;
margin-bottom: 2rem;
}
em{
display: inline-block;
padding: 0 0.3rem;
background-color: lavender;
font-style: normal;
border-radius: 7px;
color: tomato;
}
a{
text-decoration: none;
color: blue;
font-weight: bold;
}
a:hover{
color: green;
}
/* INDEX PAGE */
h1, h2, h3{
text-align: center;
color: tomato;
}
section{
padding: 2rem;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
border: 1px solid tomato;
}
ul{
padding: 0;
}
dl, dt, dd{
margin: 0;
}
dl{
margin: 3rem 0;
}
dt{
font-size: 22px;
margin-bottom: 1rem;
font-weight: bold;
}
footer{
border-top: 1px solid tomato;
margin-bottom: 2rem;
padding-top: 1rem
}
includes/count.php
<?php
/**
* Main Count class
*/
abstract class Count
{
private $ip;
private $dbh;
function __construct($database_connection)
{
$this->dbh = $database_connection;
}
public function ip()
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
//ip from share internet
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
//ip pass from proxy
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} else {
$ip = $_SERVER['HTTP_HOST'];
}
return $ip;
}
public function getArticle($url)
{
try {
$get_article = $this->dbh->prepare("SELECT * FROM articles WHERE slug = ? ");
$do = $get_article->execute( array($url) );
return $get_article->fetch();
} catch (Exception $e) {
throw new Exception("Error Getting Visitor - ".$e->getMessage(), 1);
}
}
public function articleViews($url)
{
try {
$get_article = $this->dbh->prepare("SELECT views, slug FROM articles WHERE slug = ? ");
$execute = $get_article->execute( array($url) );
return $get_article->fetch();
} catch (Exception $e) {
throw new Exception("Error url views - ".$e->getMessage(), 1);
}
}
}
includes/database.php
<?php
class Connection
{
private $host;
private $database;
private $user;
private $password;
private $dsn;
private $options = array();
protected $con;
public function __construct($host, $database, $user, $password, $options = array())
{
$this->host = $host;
$this->database = $database;
$this->user = $user;
$this->password = $password;
$this->options = !empty($options) ? $options : array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ);
$this->dsn = "mysql:host=$host;dbname=$database";
}
public function open()
{
try {
$this->con = new PDO($this->dsn, $this->user, $this->password, $this->options);
return $this->con;
} catch (PDOException $e) {
Session::set('alert', "Problem connnecting to database: " . $e->getMessage());
}
}
public function close()
{
$this->con = null;
}
}
includes/session.php
<?php
/**
* SESSION Utility class
*/
class Session
{
public function __construct(){
!isset($_SESSION) ? session_start() : null;
}
static function set($key, $value)
{
return $_SESSION[$key] = $value;
}
static function get($key)
{
return isset($_SESSION[$key]) ? $_SESSION[$key] : null;
}
static function remove($key)
{
return $_SESSION[$key] = null;
}
static function destroy()
{
session_destroy();
}
static function queryString()
{
return isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '';
}
static function makeUrl()
{
$url = trim(self::queryString(),'/');
$url = explode('=', $url);
$url = !empty($url[1]) ? $url[1] : '/';
return $url;
}
}
includes/settings.php
<?php
// replace with your settings
$settings = array(
// Database settings
'database' => array(
'host' => 'localhost',
'database' => 'counting',
'user' => 'root',
'password' => '',
),
// Solution A data
'solution_a' => array(
'session_duration' => 120, // 2 minutes
'garbage_collect_period' => 120 * 2,
),
// Solution B data
'solution_b' => array(
'avg_reading_speed' => 250, // 250 words / minute
),
);
return $settings;
includes/solution-a.php
<?php
/**
* Solution A implementation class
*/
final class SolutionA extends Count
{
private $dbh;
public function __construct($dbh)
{
$this->dbh = $dbh;
parent::__construct($this->dbh);
}
public function store($ip, $url)
{
try {
$prepare_to_save_visitor = $this->dbh->prepare("INSERT INTO visitors (ipaddress, url, created_at) VALUES(?,?,?) ");
return $prepare_to_save_visitor->execute( array($ip, $url, time()) );
} catch (Exception $e) {
throw new Exception("Error Saving Visitor - ".$e->getMessage(), 1);
}
}
/**
* Get the current visitor
* String $ip: The IP address of the visitor
* return boolean
*/
public function get($ip)
{
try {
$get_visitor = $this->dbh->prepare("SELECT * FROM visitors WHERE ipaddress = ? ORDER BY created_at DESC");
$do = $get_visitor->execute( array($ip) );
return $get_visitor->fetch();
} catch (Exception $e) {
throw new Exception("Error Getting Visitor - ".$e->getMessage(), 1);
}
}
public function getAll($ip)
{
try {
$get_visitor_urls = $this->dbh->prepare("SELECT * FROM visitors WHERE ipaddress = ? ");
$do = $get_visitor_urls->execute( array($ip) );
return $get_visitor_urls->fetchAll();
} catch (Exception $e) {
throw new Exception("Error Getting Visitor data - ".$e->getMessage(), 1);
}
}
public function url($url, $ip)
{
try {
$url_visitor = $this->dbh->prepare("SELECT * FROM visitors WHERE url = ? AND ipaddress = ? ");
$do = $url_visitor->execute( array($url, $ip) );
return $url_visitor->fetch();
} catch (Exception $e) {
throw new Exception("Error Getting Visitor - ".$e->getMessage(), 1);
}
}
public function refreshTime( $url, $visitor_ip )
{
try {
$refresher = $this->dbh->prepare("UPDATE visitors SET created_at = ? WHERE url = ? AND ipaddress = ? LIMIT 1");
return $refresher->execute( array(time(), $url, $visitor_ip) );
} catch (Exception $e) {
throw new Exception("Error refreshing time - ".$e->getMessage(), 1);
}
}
public function addViewFor($url)
{
try {
$prepare_to_increment_views = $this->dbh->prepare("UPDATE articles SET views = views + 1 WHERE slug = ? LIMIT 1");
return $prepare_to_increment_views->execute( array($url) );
} catch (Exception $e) {
throw new Exception("Error Saving Visitor - ".$e->getMessage(), 1);
}
}
public function collectGarbage($period)
{
$now = time();
$left_over_ips_life = $now - $period;
$clean_old_visitors = $this->dbh->prepare("DELETE FROM visitors WHERE created_at <= ? ");
return $clean_old_visitors->execute( array($left_over_ips_life) );
}
}
includes/solution-b.php
<?php
/**
* Solution B implementation class
*/
final class SolutionB extends Count
{
private $dbh;
public function __construct($dbh)
{
$this->dbh = $dbh;
parent::__construct($this->dbh);
}
public function store($article_id)
{
try {
$prepare_to_save_visitor = $this->dbh->prepare("INSERT INTO opens_views (article_id, opens, views) VALUES(?,?,?) ");
return $prepare_to_save_visitor->execute( array($article_id, 1, 1) );
} catch (Exception $e) {
throw new Exception("Error Saving Visitor - ".$e->getMessage(), 1);
}
}
public function tracked($article_id)
{
try {
$get_visitor = $this->dbh->prepare("SELECT * FROM opens_views WHERE article_id = ?");
$do = $get_visitor->execute( array($article_id) );
return $get_visitor->fetch();
} catch (Exception $e) {
throw new Exception("Error Getting article data - ".$e->getMessage(), 1);
}
}
public function increment($article_id, $metric = 'views')
{
switch ($metric) {
case 'views':
try {
$increment_opens = $this->dbh->prepare("UPDATE opens_views SET views = views +1 ");
return $increment_opens->execute();
} catch (Exception $e) {
throw new Exception("Error increasingt views - ".$e->getMessage(), 1);
}
break;
case 'opens':
try {
$increment_opens = $this->dbh->prepare("UPDATE opens_views SET opens = opens +1 ");
return $increment_opens->execute();
} catch (Exception $e) {
throw new Exception("Error increasingt opens - ".$e->getMessage(), 1);
}
break;
}
}
}
To avoid extending too much the tutorial I decided to spare you the details of how each line of the code works. Nonetheless, let me comment on a few lines that may require some explanation. In init_b.php
we have this portion of code which helps us find out if a visit can count either as a new or an old visit when an old visitor returns after a certain time of idleness or went to other pages for some time.
// Text size
$text_size = str_word_count($article->text);
$time_to_read_article = ceil(($text_size / $reading_speed));
$time_to_read_article *= 60; //convert into seconds
$elapsed_time_reading = $b_start_time - Session::get('article_reading_'.$article->id);
So what it does is to find the number of words in the article. We have already set a static average reading speed in the settings file. With that, we can estimate the average required time to read the article. That time is in minutes so I converted it into seconds to make my calculation easy. From there we can find out the time done on an article.
Conclusion
Overall, there is still these part when a human decision has to intervein to decide of what is a count or not. Big organizations use sophisticated algorithms and mathematics to have reasonable approximations which make it look more natural. This project is quite huge and we could take another post to explore the various moving parts. I hope you will explore the code to see how that works step by step and probably improve it to come up with your implementation.
Last updated 2024-01-11 UTC