Internet Performance Monitoring and Visualisation

Internet Dashboard

Internet is a slippery service to track due to the fluctuating and intangible nature of internet access. Some may take their Internet Service Provider’s (ISP) word that they are delivering internet performance that is up to the standard you are paying for, but not me. I can only shut my eyes at night with the confidence that my download and upload speeds, as well as latency, are up to scratch.

Internet speed checking websites are abundant, but they require a browser and only give you results from that point in time. To properly check that my ISP is delivering on their promise, I need a created a small application to automatically collect, store and visualise my internet connectivity performance. My application solution can continuously check the internet performance, archive the results and then visualise and aggregate the results.

Requirements

The Approach

We will generate the internet performance report by running a command line interface, then transform the results into a structure suited for importing into a relational database.

We will run a MySQL database running in a Docker container and schedule the run and import of the internet performance report in a crontab to import the data into the database.

Then we will hook up a Grafana container to the MySQL database so we can visualise the data set.

The following sections are an explanation of all the different components of this application.

The SpeedTest Command Line Tool

Ookla provides the speedtest command line tool which we will use to run the command line test and output the results in a csv format with the arguments: speedtest --progress=no --format=csv --output-header

Dockerised SpeedTest

For portability and stability, I prefer to package up tools into Docker containers. My speedtest Dockerfile looks like:

FROM ubuntu:bionic

ENV INSTALL_KEY=379CE192D401AB61
RUN apt-get update && apt-get install gnupg1 apt-transport-https dirmngr -y
RUN export DEB_DISTRO=bionic && \
    apt-key adv --keyserver keyserver.ubuntu.com --recv-keys $INSTALL_KEY && \
    echo "deb https://ookla.bintray.com/debian ${DEB_DISTRO} main" | tee  /etc/apt/sources.list.d/speedtest.list && \
    apt-get update && \
    apt-get install speedtest -y

RUN speedtest --accept-license

ENTRYPOINT ["speedtest"]

Place the above snippet in a file called Dockerfile and then when in the same directory as the file run the command docker build . -t speedtest to build the container.

Transform the Data

Whilst conveniently, the speedtest cli will output to csv, we need to do a slight transformation of the data to import it into the database.

The script we will use to transform the data is

import sys

filename = sys.argv[1]

with open(filename, "r") as f:
    lines = [line.strip() for line in f.readlines()]
    headings = lines[0].split(",")
    values = lines[1].split(",")

    for i, h in enumerate(headings):
        cur_heading = h
        cur_value = values[i]
        print(f"{cur_heading},{cur_value}")

This script will read the first argument (which should be a file name), then parse the results. It will print out the transformed data to standard out.

Dockerise the Transform script

Use the following Dockerfile to build a container which runs the transform script from the previous section

FROM python:3.8-alpine

COPY transform.py .

ENTRYPOINT ["python3", "transform.py"]

To build the docker container run docker build . -t transform.

Run the MySQL Database

Whilst it’s definitely possible to install and run the database directly on the host, I prefer to run the database in a container since it has a simpler installation and configuration process. With Docker, to install the database all you need to do is run a docker container, and to configure the database all we need to do is set environment variables in the container.

To ensure data persistence, we mount the data directory from the container onto the host system to ensure we don’t lose data between container restarts. Also, note we expose the port 3306 to the host so we can connect from the host to the MySQL process in the container.

version: '3.1'

services:
  db:
    image: mysql:8
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: goodpassword
      MYSQL_DATABASE: HOME
    volumes:
      - ./data:/var/lib/mysql
    ports:
      - 3306:3306
    restart: always

Start the database by recording the above yaml into a file called docker-compose.yaml and then run docker-compose up -d to start the container.

Create the table

We need a script to create a network_logs table in the database:

CREATE TABLE IF NOT EXISTS network_logs ( 
    metric VARCHAR(255),
    value DOUBLE,
    ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Record this script in a file called create.sql.

Script it all together

Now that we have a speedtest container, a transform container and a MySQL container, we can look at scripting the data importing process.

First we set some variables which we will use later:

host=localhost
user=root
password=goodpassword
database=HOME

We run the speedtest container:

docker run speedtest --progress=no --format=csv --output-header > /home/daniel/speedtest/data/data.csv

This writes the output from the speedtest into a data.csv file.

Next, we transform the data:

docker run -v /home/daniel/speedtest/data:/data transform /data/data.csv > /home/daniel/speedtest/data/transform.csv

We mount the data.csv file from the host into the docker container so it can be read, then write the output to transform.csv.

Then, we create the required tables in the database:

docker run -i --net=host -v /home/daniel/speedtest/data:/data mysql mysql --local-infile=1 -h "${host}" -u "${user}" --password="${password}" --database="${database}" < /home/daniel/speedtest/create.sql

Finally, we insert the data into the table:

docker run -i --net=host -v /home/daniel/speedtest/data:/data mysql:8 mysql --local-infile=1 -h "${host}" -u "${user}" --password="${password}" --database="${database}" < /home/daniel/speedtest/insert.sql

All together, the script should look like

#!/usr/bin/env bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
set -euox pipefail

host=localhost
user=root
password=goodpassword
database=HOME

docker run speedtest --progress=no --format=csv --output-header > /home/daniel/speedtest/data/data.csv
docker run -v /home/daniel/speedtest/data:/data transform /data/data.csv > /home/daniel/speedtest/data/transform.csv

docker run -i --net=host -v /home/daniel/speedtest/data:/data mysql mysql --local-infile=1 -h "${host}" -u "${user}" --password="${password}" --database="${database}" < /home/daniel/speedtest/create.sql
docker run -i --net=host -v /home/daniel/speedtest/data:/data mysql:8 mysql --local-infile=1 -h "${host}" -u "${user}" --password="${password}" --database="${database}" < /home/daniel/speedtest/insert.sql

Save this in a file called run.sh Note that we set the PATH variables since when we set the crontab we need to set the PATH variable outselves.

Schedule the script with Crontab

Crontabs are a convient way to schedule scripts to run on a regular schedule.

To edit the crontab file, use crontab -e. We then edit the crontab file with (Vi)[https://en.wikipedia.org/wiki/Vi] commands.

Here is a link to a reference on (how to write crontabs)[https://www.adminschoice.com/crontab-quick-reference].

And here is an example of a crontab I used to run my application:

*/5 * * * * speedtest/run.sh

This crontab runs the script speedtest/run.sh every 5 minutes.

Grafana Visualisation

Similar to the MySQL database, we run our visualisation tool Grafana with Docker:

version: '3'

services:
  grafana:
    image: grafana/grafana:6.7.3
    environment:
      TZ: Australia/Melbourne
      GF_INSTALL_PLUGINS: 'btplc-trend-box-panel,pr0ps-trackmap-panel,grafana-strava-datasource'
      GF_STRAVA_DS_DATA_PATH: /var/lib/grafana/strava
    ports:
      - 3000:3000
    volumes:
      - grafanastorage:/var/lib/grafana
    restart: always

volumes:
  grafanastorage:

Then run this file with docker-compose up -d -f <filename>.

Then in a web browser go to http://<ip address of linux server>:3000/ and login with the username admin and password admin.

You will need to add a MySQL database and enter the details of your MySQL database you created earlier.

You can then play around and try to create your own dashboard. Alternatively, if you have followed the above steps exactly, then you can copy my Grafana dashboard JSON model, found at the bottom of this document.

My Internet Performance Grafana Dashboard Model

{
  "annotations": {
    "list": [
      {
        "builtIn": 1,
        "datasource": "-- Grafana --",
        "enable": true,
        "hide": true,
        "iconColor": "rgba(0, 211, 255, 1)",
        "name": "Annotations & Alerts",
        "type": "dashboard"
      }
    ]
  },
  "editable": true,
  "gnetId": null,
  "graphTooltip": 0,
  "id": 2,
  "links": [],
  "panels": [
    {
      "aliasColors": {},
      "bars": false,
      "dashLength": 10,
      "dashes": false,
      "datasource": null,
      "fill": 1,
      "fillGradient": 0,
      "gridPos": {
        "h": 9,
        "w": 24,
        "x": 0,
        "y": 0
      },
      "hiddenSeries": false,
      "id": 4,
      "legend": {
        "alignAsTable": true,
        "avg": true,
        "current": true,
        "max": false,
        "min": false,
        "rightSide": false,
        "show": true,
        "total": false,
        "values": true
      },
      "lines": true,
      "linewidth": 1,
      "nullPointMode": "null",
      "options": {
        "dataLinks": []
      },
      "percentage": false,
      "pointradius": 2,
      "points": false,
      "renderer": "flot",
      "seriesOverrides": [],
      "spaceLength": 10,
      "stack": false,
      "steppedLine": false,
      "targets": [
        {
          "format": "time_series",
          "group": [],
          "metricColumn": "metric",
          "rawQuery": false,
          "rawSql": "SELECT\n  ts AS \"time\",\n  metric AS metric,\n  value\nFROM network_logs\nWHERE\n  $__timeFilter(ts) AND\n  metric = 'latency'\nORDER BY ts",
          "refId": "A",
          "select": [
            [
              {
                "params": [
                  "value"
                ],
                "type": "column"
              }
            ]
          ],
          "table": "network_logs",
          "timeColumn": "ts",
          "timeColumnType": "timestamp",
          "where": [
            {
              "name": "$__timeFilter",
              "params": [],
              "type": "macro"
            },
            {
              "datatype": "varchar",
              "name": "",
              "params": [
                "metric",
                "=",
                "'latency'"
              ],
              "type": "expression"
            }
          ]
        }
      ],
      "thresholds": [],
      "timeFrom": null,
      "timeRegions": [],
      "timeShift": null,
      "title": "Latency",
      "tooltip": {
        "shared": true,
        "sort": 0,
        "value_type": "individual"
      },
      "type": "graph",
      "xaxis": {
        "buckets": null,
        "mode": "time",
        "name": null,
        "show": true,
        "values": []
      },
      "yaxes": [
        {
          "format": "ms",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": true
        },
        {
          "format": "short",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": false
        }
      ],
      "yaxis": {
        "align": false,
        "alignLevel": null
      }
    },
    {
      "aliasColors": {},
      "bars": false,
      "dashLength": 10,
      "dashes": false,
      "datasource": null,
      "fill": 2,
      "fillGradient": 5,
      "gridPos": {
        "h": 13,
        "w": 24,
        "x": 0,
        "y": 9
      },
      "hiddenSeries": false,
      "id": 2,
      "legend": {
        "alignAsTable": false,
        "avg": true,
        "current": true,
        "max": false,
        "min": false,
        "rightSide": false,
        "show": true,
        "total": false,
        "values": true
      },
      "lines": true,
      "linewidth": 1,
      "nullPointMode": "null",
      "options": {
        "dataLinks": []
      },
      "percentage": false,
      "pointradius": 2,
      "points": true,
      "renderer": "flot",
      "seriesOverrides": [],
      "spaceLength": 10,
      "stack": false,
      "steppedLine": false,
      "targets": [
        {
          "format": "time_series",
          "group": [],
          "metricColumn": "metric",
          "rawQuery": false,
          "rawSql": "SELECT\n  ts AS \"time\",\n  metric AS metric,\n  value\nFROM network_logs\nWHERE\n  $__timeFilter(ts) AND\n  metric IN ('download bytes','upload bytes')\nORDER BY ts",
          "refId": "A",
          "select": [
            [
              {
                "params": [
                  "value"
                ],
                "type": "column"
              }
            ]
          ],
          "table": "network_logs",
          "timeColumn": "ts",
          "timeColumnType": "timestamp",
          "where": [
            {
              "name": "$__timeFilter",
              "params": [],
              "type": "macro"
            },
            {
              "datatype": "varchar",
              "name": "",
              "params": [
                "metric",
                "IN",
                "('download bytes','upload bytes')"
              ],
              "type": "expression"
            }
          ]
        }
      ],
      "thresholds": [],
      "timeFrom": null,
      "timeRegions": [],
      "timeShift": null,
      "title": "Speed",
      "tooltip": {
        "shared": true,
        "sort": 0,
        "value_type": "individual"
      },
      "type": "graph",
      "xaxis": {
        "buckets": null,
        "mode": "time",
        "name": null,
        "show": true,
        "values": []
      },
      "yaxes": [
        {
          "format": "Bps",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": true
        },
        {
          "format": "short",
          "label": null,
          "logBase": 1,
          "max": null,
          "min": null,
          "show": false
        }
      ],
      "yaxis": {
        "align": false,
        "alignLevel": null
      }
    }
  ],
  "schemaVersion": 22,
  "style": "dark",
  "tags": [],
  "templating": {
    "list": []
  },
  "time": {
    "from": "now-6h",
    "to": "now"
  },
  "timepicker": {
    "refresh_intervals": [
      "5s",
      "10s",
      "30s",
      "1m",
      "5m",
      "15m",
      "30m",
      "1h",
      "2h",
      "1d"
    ]
  },
  "timezone": "",
  "title": "Internet",
  "uid": "5PuTz-OGk",
  "variables": {
    "list": []
  },
  "version": 3
}